-
Notifications
You must be signed in to change notification settings - Fork 1.3k
[monitor-opentelemetry] Add RateLimitedSampler #34868
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
hectorhdzg
merged 25 commits into
Azure:main
from
hectorhdzg:hectorhdzg/rateLimitedSampler
Jul 24, 2025
Merged
Changes from all commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
6da5d8f
RateLimitedSampler draft
hectorhdzg 4ea571d
Update tests
hectorhdzg d6f88da
Format
hectorhdzg be91af0
Update sdk/monitor/monitor-opentelemetry-exporter/src/sampling/rateLi…
hectorhdzg 3a21152
Add distro integration
hectorhdzg 98d55a6
Merge branch 'hectorhdzg/rateLimitedSampler' of https://github.com/he…
hectorhdzg 70b2a5b
Adding support for OTEL_TRACES_SAMPLER env
hectorhdzg 701ee0e
Merge branch 'main' into hectorhdzg/rateLimitedSampler
hectorhdzg 4b30f38
Lint
hectorhdzg 3e0e3cd
Merge branch 'hectorhdzg/rateLimitedSampler' of https://github.com/he…
hectorhdzg 346c6e0
Add Statbeat feature signal
hectorhdzg 0383d2f
Add env config merge
hectorhdzg f5b2c85
Get parent sampling result
hectorhdzg caffb9e
Address comments
hectorhdzg 0f7d4fc
Merge branch 'main' into hectorhdzg/rateLimitedSampler
hectorhdzg 30aadf5
Format
hectorhdzg 75a253a
Merge branch 'hectorhdzg/rateLimitedSampler' of https://github.com/he…
hectorhdzg bd3e59a
Added docs
hectorhdzg f4788d2
format
hectorhdzg 1347ba0
Added warning for unsupported samplers
hectorhdzg ea6d802
Merge branch 'main' into hectorhdzg/rateLimitedSampler
hectorhdzg 8579942
Merge remote-tracking branch 'origin/master' into hectorhdzg/rateLimi…
hectorhdzg 943a810
Lint
hectorhdzg 643169a
Update
hectorhdzg 7fdfdba
Format
hectorhdzg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
139 changes: 139 additions & 0 deletions
139
sdk/monitor/monitor-opentelemetry-exporter/src/sampling/rateLimitedSampler.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}}`; | ||
} | ||
} |
103 changes: 103 additions & 0 deletions
103
sdk/monitor/monitor-opentelemetry-exporter/src/sampling/samplingUtils.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
// Copyright (c) Microsoft Corporation. | ||
hectorhdzg marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// 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 { | ||
hectorhdzg marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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; | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.