Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6da5d8f
RateLimitedSampler draft
hectorhdzg Jun 18, 2025
4ea571d
Update tests
hectorhdzg Jun 24, 2025
d6f88da
Format
hectorhdzg Jun 24, 2025
be91af0
Update sdk/monitor/monitor-opentelemetry-exporter/src/sampling/rateLi…
hectorhdzg Jun 24, 2025
3a21152
Add distro integration
hectorhdzg Jun 24, 2025
98d55a6
Merge branch 'hectorhdzg/rateLimitedSampler' of https://github.com/he…
hectorhdzg Jun 24, 2025
70b2a5b
Adding support for OTEL_TRACES_SAMPLER env
hectorhdzg Jun 24, 2025
701ee0e
Merge branch 'main' into hectorhdzg/rateLimitedSampler
hectorhdzg Jun 24, 2025
4b30f38
Lint
hectorhdzg Jun 24, 2025
3e0e3cd
Merge branch 'hectorhdzg/rateLimitedSampler' of https://github.com/he…
hectorhdzg Jun 24, 2025
346c6e0
Add Statbeat feature signal
hectorhdzg Jun 25, 2025
0383d2f
Add env config merge
hectorhdzg Jun 26, 2025
f5b2c85
Get parent sampling result
hectorhdzg Jun 30, 2025
caffb9e
Address comments
hectorhdzg Jun 30, 2025
0f7d4fc
Merge branch 'main' into hectorhdzg/rateLimitedSampler
hectorhdzg Jun 30, 2025
30aadf5
Format
hectorhdzg Jun 30, 2025
75a253a
Merge branch 'hectorhdzg/rateLimitedSampler' of https://github.com/he…
hectorhdzg Jun 30, 2025
bd3e59a
Added docs
hectorhdzg Jul 7, 2025
f4788d2
format
hectorhdzg Jul 7, 2025
1347ba0
Added warning for unsupported samplers
hectorhdzg Jul 7, 2025
ea6d802
Merge branch 'main' into hectorhdzg/rateLimitedSampler
hectorhdzg Jul 16, 2025
8579942
Merge remote-tracking branch 'origin/master' into hectorhdzg/rateLimi…
hectorhdzg Jul 16, 2025
943a810
Lint
hectorhdzg Jul 16, 2025
643169a
Update
hectorhdzg Jul 23, 2025
7fdfdba
Format
hectorhdzg Jul 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions sdk/monitor/monitor-opentelemetry-exporter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@

- Added customer-facing statsbeat preview.

### Features Added

- Add RateLimitedSampler.

### Other Changes

- Ensure that the longIntervalStatsbeat reader is properly bound to a MetricProducer.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ export class AzureMonitorTraceExporter extends AzureMonitorBaseExporter implemen
shutdown(): Promise<void>;
}

// @public
export class RateLimitedSampler implements Sampler {
constructor(tracesPerSecond: number);
getSampleRate(): number;
shouldSample(context: Context, traceId: string, spanName: string, spanKind: SpanKind, attributes: Attributes, links: Link[]): SamplingResult;
toString(): string;
}

// @public
export enum ServiceApiVersion {
V2 = "2020-09-15_Preview"
Expand Down
3 changes: 2 additions & 1 deletion sdk/monitor/monitor-opentelemetry-exporter/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

export { ApplicationInsightsSampler } from "./sampling.js";
export { ApplicationInsightsSampler } from "./sampling/percentageSampler.js";
export { RateLimitedSampler } from "./sampling/rateLimitedSampler.js";
export { AzureMonitorBaseExporter } from "./export/base.js";
export { AzureMonitorTraceExporter } from "./export/trace.js";
export { AzureMonitorMetricExporter } from "./export/metric.js";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import type { Link, Attributes, SpanKind, Context } from "@opentelemetry/api";
import { TraceFlags, trace } from "@opentelemetry/api";
import type { Sampler, SamplingResult } from "@opentelemetry/sdk-trace-base";
import { SamplingDecision } from "@opentelemetry/sdk-trace-base";
import { AzureMonitorSampleRate } from "./utils/constants/applicationinsights.js";
import { shouldSample } from "./samplingUtils.js";

/**
* ApplicationInsightsSampler is responsible for the following:
Expand Down Expand Up @@ -53,41 +52,7 @@ export class ApplicationInsightsSampler implements Sampler {
// @ts-expect-error unused argument
links: Link[],
): SamplingResult {
let isSampledIn = false;
attributes = attributes || {};

// Try to get the parent sampling result first
const parentSpan = trace.getSpan(context);
const parentSpanContext = parentSpan?.spanContext();

if (
parentSpanContext &&
trace.isSpanContextValid(parentSpanContext) &&
!parentSpanContext.isRemote
) {
// If the parent span is valid and not remote, we can use its sample rate
const parentSampleRate = Number((parentSpan as any).attributes?.[AzureMonitorSampleRate]);
if (!isNaN(parentSampleRate)) {
this._sampleRate = parentSampleRate;
}
isSampledIn = (parentSpanContext.traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED;
} else {
// If no parent sampling result, we use the local sampling logic
if (this._sampleRate === 100) {
isSampledIn = true;
} else if (this._sampleRate === 0) {
isSampledIn = false;
} else {
isSampledIn = this._getSamplingHashCode(traceId) < this._sampleRate;
}
}
// Only send the sample rate if it's not 100
if (this._sampleRate !== 100) {
// Add sample rate as span attribute
attributes[AzureMonitorSampleRate] = this._sampleRate;
}

return isSampledIn
return shouldSample(this._sampleRate, context, traceId, attributes)
? { decision: SamplingDecision.RECORD_AND_SAMPLED, attributes: attributes }
: { decision: SamplingDecision.NOT_RECORD, attributes: attributes };
}
Expand All @@ -98,26 +63,4 @@ export class ApplicationInsightsSampler implements Sampler {
public toString(): string {
return `ApplicationInsightsSampler{${this.samplingRatio}}`;
}

private _getSamplingHashCode(input: string): number {
const csharpMin = -2147483648;
const csharpMax = 2147483647;
let hash = 5381;

if (!input) {
return 0;
}

while (input.length < 8) {
input = input + input;
}

for (let i = 0; i < input.length; i++) {
// JS doesn't respond to integer overflow by wrapping around. Simulate it with bitwise operators ( | 0)
hash = ((((hash << 5) + hash) | 0) + input.charCodeAt(i)) | 0;
}

hash = hash <= csharpMin ? csharpMax : Math.abs(hash);
return (hash / csharpMax) * 100;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import type { Link, Attributes, SpanKind, Context } from "@opentelemetry/api";
import type { Sampler, SamplingResult } from "@opentelemetry/sdk-trace-base";
import { SamplingDecision } from "@opentelemetry/sdk-trace-base";
import { roundDownToNearest, shouldSample } from "./samplingUtils.js";

type RateLimitedSamplerState = {
effectiveWindowCount: number;
effectiveWindowNanos: number;
lastNanoTime: number;
};

/**
* RateLimitedSampler is responsible for the following:
* - Implements a rate-limiting sampling strategy based on a specified number of requests per second.
* - Dynamically adjusts the sampling rate based on the time elapsed since the last sample.
* - Provides a sampling rate that can be used to determine whether a span should be recorded.
* @param requestsPerSecond -
*/
export class RateLimitedSampler implements Sampler {
private readonly nanoTimeSupplier: () => number;
private readonly inverseAdaptationTimeNanos: number;
private readonly targetSpansPerNanosecondLimit: number;
private state: RateLimitedSamplerState;
private readonly roundToNearest: boolean;
private readonly tracesPerSecond: number;

/**
* Initializes a new instance of the RateLimitedSampler class.
* @param tracesPerSecond - The maximum number of traces to sample per second.
* @throws Error if tracesPerSecond is negative.
*/
constructor(tracesPerSecond: number) {
this.tracesPerSecond = tracesPerSecond;
if (this.tracesPerSecond < 0.0) {
throw new Error("Limit for sampled traces per second must be nonnegative");
}
const adaptationTimeSeconds = 0.1;
this.nanoTimeSupplier = () => Number(process.hrtime.bigint());
this.inverseAdaptationTimeNanos = 1e-9 / adaptationTimeSeconds;
this.targetSpansPerNanosecondLimit = 1e-9 * this.tracesPerSecond;
const now = this.nanoTimeSupplier();
this.state = {
effectiveWindowCount: 0,
effectiveWindowNanos: 0,
lastNanoTime: now,
};
this.roundToNearest = true;
}

/**
* Updates the state of the sampler based on the current time.
* This method calculates the effective window count and nanos based on the time elapsed since the last sample.
* @param oldState - The previous state of the sampler.
* @param currentNanoTime - The current time in nanoseconds.
* @returns The updated state of the sampler.
*/
private updateState(
oldState: RateLimitedSamplerState,
currentNanoTime: number,
): RateLimitedSamplerState {
if (currentNanoTime <= oldState.lastNanoTime) {
return {
effectiveWindowCount: oldState.effectiveWindowCount + 1,
effectiveWindowNanos: oldState.effectiveWindowNanos,
lastNanoTime: oldState.lastNanoTime,
};
}
const nanoTimeDelta = currentNanoTime - oldState.lastNanoTime;
const decayFactor = Math.exp(-nanoTimeDelta * this.inverseAdaptationTimeNanos);
const currentEffectiveWindowCount = oldState.effectiveWindowCount * decayFactor + 1;
const currentEffectiveWindowNanos = oldState.effectiveWindowNanos * decayFactor + nanoTimeDelta;
return {
effectiveWindowCount: currentEffectiveWindowCount,
effectiveWindowNanos: currentEffectiveWindowNanos,
lastNanoTime: currentNanoTime,
};
}

/**
* Gets the current sample rate based on the effective window count and nanos.
* This method calculates the sampling probability and returns it as a percentage.
* If `roundToNearest` is true, it rounds down the sampling percentage to the nearest whole number.
* @returns The current sample rate as a percentage.
*/
public getSampleRate(): number {
const currentNanoTime = this.nanoTimeSupplier();
this.state = this.updateState(this.state, currentNanoTime);

const samplingProbability =
(this.state.effectiveWindowNanos * this.targetSpansPerNanosecondLimit) /
this.state.effectiveWindowCount;
let samplingPercentage = 100 * Math.min(samplingProbability, 1);

if (this.roundToNearest) {
samplingPercentage = roundDownToNearest(samplingPercentage);
}
return samplingPercentage;
}

/**
* Checks whether span needs to be created and tracked.
*
* @param context - Parent Context which may contain a span.
* @param traceId - traceId of the span to be created. It can be different from the
* traceId in the {@link SpanContext}. Typically in situations when the
* span to be created starts a new trace.
* @param spanName - Name of the span to be created.
* @param spanKind - Kind of the span to be created.
* @param attributes - Initial set of SpanAttributes for the Span being constructed.
* @param links - Collection of links that will be associated with the Span to
* be created. Typically useful for batch operations.
* @returns a {@link SamplingResult}.
*/
public shouldSample(
context: Context,
traceId: string,
// @ts-expect-error unused argument
spanName: string,
// @ts-expect-error unused argument
spanKind: SpanKind,
attributes: Attributes,
// @ts-expect-error unused argument
links: Link[],
): SamplingResult {
const sampleRate = this.getSampleRate();
return shouldSample(sampleRate, context, traceId, attributes)
? { decision: SamplingDecision.RECORD_AND_SAMPLED, attributes: attributes }
: { decision: SamplingDecision.NOT_RECORD, attributes: attributes };
}

/**
* Return Sampler description
*/
public toString(): string {
return `RateLimitedSampler{${this.tracesPerSecond}}`;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type { Attributes, Context } from "@opentelemetry/api";
import { trace, TraceFlags } from "@opentelemetry/api";
import { AzureMonitorSampleRate } from "../utils/constants/applicationinsights.js";

/**
* Computes a deterministic hash code from a string input (typically a trace ID)
* and returns a value between 0 and 100 for sampling decisions.
*
* This function replicates the C# hash algorithm used in Application Insights
* to ensure consistent sampling decisions across different SDKs and languages.
* The same trace ID will always produce the same hash value, enabling
* distributed sampling where all spans in a trace are sampled consistently.
*
* @param input - The input string to hash (usually a trace ID)
* @returns A number between 0 and 100 representing the hash-based sampling score
*/
export function getSamplingHashCode(input: string): number {
const csharpMin = -2147483648;
const csharpMax = 2147483647;
let hash = 5381;

if (!input) {
return 0;
}

// Ensure input is at least 8 characters long by repeating it
let processedInput = input;
while (processedInput.length < 8) {
processedInput = processedInput + processedInput;
}

// Compute hash using a variation of djb2 algorithm with C# integer overflow simulation
// This uses hash * 33 + c (where hash << 5 + hash equals hash * 33)
for (let i = 0; i < processedInput.length; i++) {
// JS doesn't respond to integer overflow by wrapping around. Simulate it with bitwise operators ( | 0)
hash = ((((hash << 5) + hash) | 0) + processedInput.charCodeAt(i)) | 0;
}

// Normalize hash to positive value and convert to 0-100 range
hash = hash <= csharpMin ? csharpMax : Math.abs(hash);
return (hash / csharpMax) * 100;
}

export function roundDownToNearest(samplingPercentage: number): number {
if (samplingPercentage === 0) {
return 0;
}
const itemCount = 100 / samplingPercentage;
return 100.0 / Math.ceil(itemCount);
}

export function shouldSample(
samplePercentage: number,
context: Context,
traceId: string,
attributes: Attributes,
): boolean {
let sampleRate = samplePercentage;
let isSampled = undefined;

if (sampleRate === 100) {
isSampled = true;
} else if (sampleRate === 0) {
isSampled = false;
} else {
// Try to get the parent sampling result first
const parentSpan = trace.getSpan(context);
const parentSpanContext = parentSpan?.spanContext();
if (
parentSpanContext &&
trace.isSpanContextValid(parentSpanContext) &&
!parentSpanContext.isRemote
) {
if ((parentSpanContext.traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED) {
isSampled = true;
} else if ((parentSpanContext.traceFlags & TraceFlags.NONE) === TraceFlags.NONE) {
isSampled = false;
}
// If the parent span is valid and not remote, we can use its sample rate
const parentSampleRate = Number((parentSpan as any).attributes?.[AzureMonitorSampleRate]);
if (!isNaN(parentSampleRate)) {
sampleRate = Number(parentSampleRate);
}
}
}

// Only add sample rate attribute if it's not 100
if (sampleRate !== 100) {
// Add sample rate as span attribute
attributes = attributes || {};
attributes[AzureMonitorSampleRate] = sampleRate;
}

if (isSampled === undefined) {
const samplingHashCode = getSamplingHashCode(traceId);
return samplingHashCode < sampleRate;
} else {
return isSampled;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import type {
MetricDataPoint,
} from "../generated/index.js";
import { createTagsFromResource } from "./common.js";
import { BreezePerformanceCounterNames, OTelPerformanceCounterNames, Tags } from "../types.js";
import type { Tags } from "../types.js";
import { BreezePerformanceCounterNames, OTelPerformanceCounterNames } from "../types.js";
import {
ENV_OTEL_METRICS_EXPORTER,
ENV_OTLP_METRICS_ENDPOINT,
Expand Down
Loading