Skip to content
tnsaiDocs
GitHub

Sampling

Production agents emit many events per second: chat chunks, tool calls, memory writes, hook invocations. Shipping all of them is expensive, and keeping only errors makes debugging hard. The sampling SPI in tnsai-quality (com.tnsai.quality.sampling) decides per event whether to capture, drop or down-sample, from "capture everything" in development to "errors always, a share of the rest" in production.

The SPI is EventSamplingPolicy. Default implementations (AlwaysCapturePolicy, HeadBasedSamplingPolicy, PriorityBasedSamplingPolicy, ErrorAlwaysPolicy) ship with the framework. Operators wire a policy into their event publisher via the SamplingAgentEventPublisher decorator.

Quick start

SamplingAgentEventPublisher implements com.tnsai.observability.events.AgentEventPublisher (the logging fan-out SPI). That is a different type from com.tnsai.events.TnsAIEventPublisher, which is what Agent.setEventPublisher(...) accepts. Wrap the observability publisher, and do not pass the sampler to the agent.

package com.example.tnsai.docs;

import com.tnsai.observability.events.AgentEventPublisher;
import com.tnsai.observability.events.Slf4jAgentEventPublisher;
import com.tnsai.quality.sampling.EventSamplingPolicy;
import com.tnsai.quality.sampling.SamplingAgentEventPublisher;
import com.tnsai.quality.sampling.SamplingPolicies;

public final class SamplingPublisherExample {
    private SamplingPublisherExample() {}

    public static AgentEventPublisher productionSampler() {
        EventSamplingPolicy policy = SamplingPolicies.production();
        AgentEventPublisher slf4j = new Slf4jAgentEventPublisher();
        return new SamplingAgentEventPublisher(slf4j, policy);
    }
}

Decision shape

SamplingDecision is a sealed interface with three variants. Java 21 exhaustive switch makes the publisher's dispatch compile-checked:

DecisionMeaning
CaptureCapture at full fidelity. No downstream rate adjustment needed.
DropDiscard the event. Never reaches the underlying publisher.
CaptureDownsampled(rate)Capture, but record the configured rate so downstream aggregators (Prometheus, Datadog) can extrapolate volume (count += 1 / rate).

Capture.INSTANCE and Drop.INSTANCE are reusable singletons, so most policies don't allocate a fresh decision per call.

Default policies

AlwaysCapturePolicy

The dev / single-tenant default: every event captured. Use the singleton: AlwaysCapturePolicy.INSTANCE.

EventSamplingPolicy verbose = AlwaysCapturePolicy.INSTANCE;

HeadBasedSamplingPolicy

Coin-flip per event at a fixed rate. Stateless, lock-free, the simplest production policy. Returns CaptureDownsampled(rate) when captured.

EventSamplingPolicy p = new HeadBasedSamplingPolicy(0.10);   // 10%

Trade-off: no per-session affinity. A captured chat may have some chunks captured and others not.

PriorityBasedSamplingPolicy

Per-EventLevel rate. Default rates encode the production observability rule of thumb: never drop anything that's already a problem:

LevelDefault rate
CRITICAL1.0 (always)
ERROR1.0 (always)
WARN1.0 (always)
INFO0.1 (10%)
DEBUG0.01 (1%)
// Defaults
EventSamplingPolicy p = PriorityBasedSamplingPolicy.withDefaults();

// Custom rates per level
Map<EventLevel, Double> overrides = Map.of(
        EventLevel.INFO, 0.5,
        EventLevel.DEBUG, 0.0           // hard-drop DEBUG
);
EventSamplingPolicy p = new PriorityBasedSamplingPolicy(overrides);

ErrorAlwaysPolicy

Simpler than PriorityBasedSamplingPolicy: one switch on level, no per-level map:

  • WARN / ERROR / CRITICAL → always Capture
  • INFO / DEBUG → coin-flip at infoDebugRate
EventSamplingPolicy p = new ErrorAlwaysPolicy(0.05);     // 5% INFO/DEBUG
EventSamplingPolicy d = ErrorAlwaysPolicy.withDefault(); // 10% INFO/DEBUG

Use when "is this a problem?" is the only distinction that matters for capture.

RuleBasedSamplingPolicy

Ordered list of declarative rules, first match wins. Each rule combines an event-kind glob, a minimum level, an optional tenant filter, and an outcome (CAPTURE / DROP / SAMPLE_AT_RATE).

Glob shapes:

  • "*": matches any kind
  • "chat.*": matches one component after the prefix (chat.chunk ✓, chat.tool.invoked ✗)
  • "tool.invoked": exact match
EventSamplingPolicy p = RuleBasedSamplingPolicy.builder()
        .capture("tool.invoked")                      // always capture
        .drop("internal.heartbeat")                   // hard-drop noise
        .sample("chat.chunk", 0.001)                  // 0.1% of chunks
        .capture("*", EventLevel.WARN, null)          // WARN+ everywhere
        .defaultDrop()                                // anything unmatched
        .build();

AdaptiveSamplingPolicy

Wraps a delegate policy and degrades gracefully under measured backpressure. The consumer supplies a BackpressureSignal, a double in [0.0, 1.0] representing "fraction of capacity in use" (queue depth, ack lag, Resilience4j RateLimiter saturation, …). The policy reads it and:

  • below lowWaterMark (default 0.5) → delegate runs unchanged
  • between low and high → linearly downsample the delegate's Capture decisions
  • at or above highWaterMark (default 0.9) → drop everything below criticalLevel (default ERROR)

Critical-and-above events always survive, regardless of pressure.

BackpressureSignal queueSignal = () -> queueDepth.get() / (double) MAX_DEPTH;

EventSamplingPolicy p = new AdaptiveSamplingPolicy(
        SamplingPolicies.production(),     // delegate
        queueSignal);                       // sane defaults: 0.5 / 0.9 / ERROR

// Or with custom thresholds:
EventSamplingPolicy p2 = new AdaptiveSamplingPolicy(
        delegate, queueSignal,
        0.4, 0.85, EventLevel.WARN);

Tracks droppedDueToPressureCount() for monitoring how often pressure forced a drop above what the delegate would have done.

Named factories: SamplingPolicies

Three shapes for the most common operator intents:

FactoryReturnsWhen to use
SamplingPolicies.verbose()AlwaysCapturePolicy.INSTANCEdev / staging / single tenant
SamplingPolicies.defaults()PriorityBasedSamplingPolicy.withDefaults()production with INFO/DEBUG distinction
SamplingPolicies.production()ErrorAlwaysPolicy.withDefault()production with strict "errors-always"
EventSamplingPolicy p = SamplingPolicies.production();

Per-tenant policies

TenantPolicySamplingPolicy dispatches each call to a per-tenant policy based on EventContext.tenantId. A multi-tenant agent group can apply different rates per tenant without the publisher knowing tenant boundaries.

Resolution order:

  1. EventContext.tenantId from SamplingInput.context
  2. Configured default policy, the fallback for null / blank / unmatched
EventSamplingPolicy dispatcher = TenantPolicySamplingPolicy.builder()
        .tenant("acme",   SamplingPolicies.verbose())            // power user — keep everything
        .tenant("globex", new ErrorAlwaysPolicy(0.001))          // chatty — aggressive sampling
        .defaultPolicy(SamplingPolicies.production())            // everyone else
        .build();

The dispatch happens on every call (no per-thread cache) so a single agent group serving multiple tenants doesn't leak rates across boundaries.

Wiring into the publisher

SamplingAgentEventPublisher is a decorator over the AgentEventPublisher SPI in tnsai-core. Each publish call builds a SamplingInput from event.eventType() + EventLevel + EventContext, asks the policy, and publishes only when SamplingDecision.shouldPublish() is true.

AgentEventPublisher base    = new Slf4jAgentEventPublisher();
AgentEventPublisher sampled = new SamplingAgentEventPublisher(base, policy);

Constructor variants:

ConstructorUse when
(delegate, policy)bare publish(event) calls fall back to EventContext.empty()
(delegate, policy, contextSupplier)per-call tenant / agent context flows in via the supplier

Both publish overloads (level-aware and bare) route through the policy. Bare publish(event) defaults to EventLevel.INFO. Null event passes straight through to the delegate without consulting the policy.

LLM calls

SamplingLLMCallPublisher applies the same policies to LLMCallLog events from CapturingLLMClient. Each call is sampled as event kind llm.called, at level ERROR when it failed and INFO otherwise, with the call's own EventContext, so ErrorAlwaysPolicy keeps every failed call and tenant policies see the tenant.

LLMCallPublisher sampled = new SamplingLLMCallPublisher(
        new RedactingLLMCallPublisher(sink, redactor),   // redact inside
        SamplingPolicies.production());                  // sample outside

LLMClient observed = new CapturingLLMClient(client, JsonLLMPricingRegistry.defaultRegistry(), sampled);

With sampling outside, the redactor only runs on calls that will ship.

Composition with redaction

SamplingAgentEventPublisher and RedactingAgentEventPublisher are independent decorators, and both implement the same SPI. Stack order chooses which runs first:

// Order A: sample first, redact what survives.
//   Cheaper when drop rate is high — redactor only runs on captured events.
AgentEventPublisher pub = new RedactingAgentEventPublisher(
        new SamplingAgentEventPublisher(base, policy), redactor);

// Order B: redact every event, then sample.
//   Uniform redaction cost; slightly easier to debug ("what would have been emitted?").
AgentEventPublisher pub = new SamplingAgentEventPublisher(
        new RedactingAgentEventPublisher(base, redactor), policy);

Pick order A in production (where drop rate is meaningful) and order B when debugging redaction patterns.

Rate limiting (last-resort backstop)

Sampling makes per-event capture/drop decisions. A rate limiter is a per-tenant volume cap that protects the aggregator when sampling alone isn't enough. The two compose: sampling reduces volume cheaply, the limiter bounds the survivors.

// Per-tenant token bucket backed by Resilience4j. The no-argument constructor allows 1000 events a second.
Resilience4jEventRateLimiter limiter = new Resilience4jEventRateLimiter(1000, Duration.ofSeconds(1));
limiter.registerTenant("noisy-tenant", RateLimiterConfig.custom()      // io.github.resilience4j.ratelimiter
        .limitForPeriod(100)
        .limitRefreshPeriod(Duration.ofSeconds(1))
        .timeoutDuration(Duration.ZERO)
        .build());

AgentEventPublisher slf4j   = new Slf4jAgentEventPublisher();
AgentEventPublisher rate    = new RateLimitedAgentEventPublisher(slf4j, limiter);
AgentEventPublisher sampled = new SamplingAgentEventPublisher(rate, policy);

Stack order matters. sampled → rate → sink (above) lets the cheap sampling decision run first, so the rate limiter only sees what survived. Reverse the stack (rate → sampled → sink) when the limiter's per-tenant cap matters more than sampling cost.

The limiter returns a Decision:

  • ACCEPT → publish
  • DROP → silently swallow (with a counter increment for monitoring)
  • DEFER → opt-in deferred path. The default decorator drops, so override onDefer to queue

Resilience4jEventRateLimiter#flushDropCounters() returns a per-tenant snapshot (and clears) so a periodic flusher can emit one ratelimit.dropped aggregate event per minute instead of suppressing the signal entirely.

SPI summary

TypeRole
EventSamplingPolicyThe SPI, with the single method sample(SamplingInput) → SamplingDecision.
SamplingDecisionSealed interface: Capture, Drop, CaptureDownsampled(rate).
SamplingInputPer-call frozen view: eventKind, level, context.
AlwaysCapturePolicyCapture every event. The dev default.
HeadBasedSamplingPolicyCoin-flip at fixed rate.
PriorityBasedSamplingPolicyPer-EventLevel rates with sensible defaults.
ErrorAlwaysPolicyWARN+ always, INFO/DEBUG at one rate.
RuleBasedSamplingPolicyDeclarative rule list (glob + level + tenant + outcome), first match wins.
AdaptiveSamplingPolicyWraps a delegate and downsamples linearly under backpressure.
SamplingPoliciesNamed factories: verbose, defaults, production.
TenantPolicySamplingPolicyPer-tenant dispatcher.
SamplingAgentEventPublisherAgentEventPublisher decorator that consults the policy.
EventRateLimiterPer-tenant token-bucket SPI returning ACCEPT / DROP / DEFER.
Resilience4jEventRateLimiterDefault limiter with per-tenant configs and drop counters.
RateLimitedAgentEventPublisherDecorator that consults the limiter before delegating.

Trade-offs

  • No per-session affinity: every policy decides per event in isolation. A captured session may have some chunks dropped.
  • CaptureDownsampled extrapolation cost: downstream aggregators must know to scale counters by 1 / rate. If your aggregator doesn't support per-event rates, use Capture (no rate) policies only.
  • Statistical variance: at low rates over short windows, observed capture counts vary widely. Plan dashboards over windows large enough that the law of large numbers settles.
  • Cost vs. fidelity: aggressive sampling reduces aggregator cost but loses signal for rare bugs. Production agents typically run SamplingPolicies.production() (10% INFO/DEBUG) until cost forces tighter rates.
  • LLM calls: the LLMCallLog events that SamplingLLMCallPublisher samples
  • Monitor agents: logs, traces, metrics and health checks
  • Redaction: remove personal data before events leave the process
  • Cost: per-tenant budgets