Skip to content

Commit dfe63da

Browse files
hectorhdzgCopilot
andauthored
[monitor-opentelemetry] Add RateLimitedSampler (#34868)
### Packages impacted by this PR @azure/monitor-opentelemetry-exporter Adding support for RateLimitedSampler, inspired by Java Application Insights sampler https://github.com/microsoft/ApplicationInsights-Java/blob/main/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/sampling/RateLimitedSamplingPercentage.java --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 12aea3a commit dfe63da

File tree

24 files changed

+561
-81
lines changed

24 files changed

+561
-81
lines changed

sdk/monitor/monitor-opentelemetry-exporter/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020

2121
- Added customer-facing statsbeat preview.
2222

23+
### Features Added
24+
25+
- Add RateLimitedSampler.
26+
2327
### Other Changes
2428

2529
- Ensure that the longIntervalStatsbeat reader is properly bound to a MetricProducer.

sdk/monitor/monitor-opentelemetry-exporter/review/monitor-opentelemetry-exporter-node.api.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,14 @@ export class AzureMonitorTraceExporter extends AzureMonitorBaseExporter implemen
7979
shutdown(): Promise<void>;
8080
}
8181

82+
// @public
83+
export class RateLimitedSampler implements Sampler {
84+
constructor(tracesPerSecond: number);
85+
getSampleRate(): number;
86+
shouldSample(context: Context, traceId: string, spanName: string, spanKind: SpanKind, attributes: Attributes, links: Link[]): SamplingResult;
87+
toString(): string;
88+
}
89+
8290
// @public
8391
export enum ServiceApiVersion {
8492
V2 = "2020-09-15_Preview"

sdk/monitor/monitor-opentelemetry-exporter/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4-
export { ApplicationInsightsSampler } from "./sampling.js";
4+
export { ApplicationInsightsSampler } from "./sampling/percentageSampler.js";
5+
export { RateLimitedSampler } from "./sampling/rateLimitedSampler.js";
56
export { AzureMonitorBaseExporter } from "./export/base.js";
67
export { AzureMonitorTraceExporter } from "./export/trace.js";
78
export { AzureMonitorMetricExporter } from "./export/metric.js";
Lines changed: 2 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33
import type { Link, Attributes, SpanKind, Context } from "@opentelemetry/api";
4-
import { TraceFlags, trace } from "@opentelemetry/api";
54
import type { Sampler, SamplingResult } from "@opentelemetry/sdk-trace-base";
65
import { SamplingDecision } from "@opentelemetry/sdk-trace-base";
7-
import { AzureMonitorSampleRate } from "./utils/constants/applicationinsights.js";
6+
import { shouldSample } from "./samplingUtils.js";
87

98
/**
109
* ApplicationInsightsSampler is responsible for the following:
@@ -53,41 +52,7 @@ export class ApplicationInsightsSampler implements Sampler {
5352
// @ts-expect-error unused argument
5453
links: Link[],
5554
): SamplingResult {
56-
let isSampledIn = false;
57-
attributes = attributes || {};
58-
59-
// Try to get the parent sampling result first
60-
const parentSpan = trace.getSpan(context);
61-
const parentSpanContext = parentSpan?.spanContext();
62-
63-
if (
64-
parentSpanContext &&
65-
trace.isSpanContextValid(parentSpanContext) &&
66-
!parentSpanContext.isRemote
67-
) {
68-
// If the parent span is valid and not remote, we can use its sample rate
69-
const parentSampleRate = Number((parentSpan as any).attributes?.[AzureMonitorSampleRate]);
70-
if (!isNaN(parentSampleRate)) {
71-
this._sampleRate = parentSampleRate;
72-
}
73-
isSampledIn = (parentSpanContext.traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED;
74-
} else {
75-
// If no parent sampling result, we use the local sampling logic
76-
if (this._sampleRate === 100) {
77-
isSampledIn = true;
78-
} else if (this._sampleRate === 0) {
79-
isSampledIn = false;
80-
} else {
81-
isSampledIn = this._getSamplingHashCode(traceId) < this._sampleRate;
82-
}
83-
}
84-
// Only send the sample rate if it's not 100
85-
if (this._sampleRate !== 100) {
86-
// Add sample rate as span attribute
87-
attributes[AzureMonitorSampleRate] = this._sampleRate;
88-
}
89-
90-
return isSampledIn
55+
return shouldSample(this._sampleRate, context, traceId, attributes)
9156
? { decision: SamplingDecision.RECORD_AND_SAMPLED, attributes: attributes }
9257
: { decision: SamplingDecision.NOT_RECORD, attributes: attributes };
9358
}
@@ -98,26 +63,4 @@ export class ApplicationInsightsSampler implements Sampler {
9863
public toString(): string {
9964
return `ApplicationInsightsSampler{${this.samplingRatio}}`;
10065
}
101-
102-
private _getSamplingHashCode(input: string): number {
103-
const csharpMin = -2147483648;
104-
const csharpMax = 2147483647;
105-
let hash = 5381;
106-
107-
if (!input) {
108-
return 0;
109-
}
110-
111-
while (input.length < 8) {
112-
input = input + input;
113-
}
114-
115-
for (let i = 0; i < input.length; i++) {
116-
// JS doesn't respond to integer overflow by wrapping around. Simulate it with bitwise operators ( | 0)
117-
hash = ((((hash << 5) + hash) | 0) + input.charCodeAt(i)) | 0;
118-
}
119-
120-
hash = hash <= csharpMin ? csharpMax : Math.abs(hash);
121-
return (hash / csharpMax) * 100;
122-
}
12366
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
import type { Link, Attributes, SpanKind, Context } from "@opentelemetry/api";
4+
import type { Sampler, SamplingResult } from "@opentelemetry/sdk-trace-base";
5+
import { SamplingDecision } from "@opentelemetry/sdk-trace-base";
6+
import { roundDownToNearest, shouldSample } from "./samplingUtils.js";
7+
8+
type RateLimitedSamplerState = {
9+
effectiveWindowCount: number;
10+
effectiveWindowNanos: number;
11+
lastNanoTime: number;
12+
};
13+
14+
/**
15+
* RateLimitedSampler is responsible for the following:
16+
* - Implements a rate-limiting sampling strategy based on a specified number of requests per second.
17+
* - Dynamically adjusts the sampling rate based on the time elapsed since the last sample.
18+
* - Provides a sampling rate that can be used to determine whether a span should be recorded.
19+
* @param requestsPerSecond -
20+
*/
21+
export class RateLimitedSampler implements Sampler {
22+
private readonly nanoTimeSupplier: () => number;
23+
private readonly inverseAdaptationTimeNanos: number;
24+
private readonly targetSpansPerNanosecondLimit: number;
25+
private state: RateLimitedSamplerState;
26+
private readonly roundToNearest: boolean;
27+
private readonly tracesPerSecond: number;
28+
29+
/**
30+
* Initializes a new instance of the RateLimitedSampler class.
31+
* @param tracesPerSecond - The maximum number of traces to sample per second.
32+
* @throws Error if tracesPerSecond is negative.
33+
*/
34+
constructor(tracesPerSecond: number) {
35+
this.tracesPerSecond = tracesPerSecond;
36+
if (this.tracesPerSecond < 0.0) {
37+
throw new Error("Limit for sampled traces per second must be nonnegative");
38+
}
39+
const adaptationTimeSeconds = 0.1;
40+
this.nanoTimeSupplier = () => Number(process.hrtime.bigint());
41+
this.inverseAdaptationTimeNanos = 1e-9 / adaptationTimeSeconds;
42+
this.targetSpansPerNanosecondLimit = 1e-9 * this.tracesPerSecond;
43+
const now = this.nanoTimeSupplier();
44+
this.state = {
45+
effectiveWindowCount: 0,
46+
effectiveWindowNanos: 0,
47+
lastNanoTime: now,
48+
};
49+
this.roundToNearest = true;
50+
}
51+
52+
/**
53+
* Updates the state of the sampler based on the current time.
54+
* This method calculates the effective window count and nanos based on the time elapsed since the last sample.
55+
* @param oldState - The previous state of the sampler.
56+
* @param currentNanoTime - The current time in nanoseconds.
57+
* @returns The updated state of the sampler.
58+
*/
59+
private updateState(
60+
oldState: RateLimitedSamplerState,
61+
currentNanoTime: number,
62+
): RateLimitedSamplerState {
63+
if (currentNanoTime <= oldState.lastNanoTime) {
64+
return {
65+
effectiveWindowCount: oldState.effectiveWindowCount + 1,
66+
effectiveWindowNanos: oldState.effectiveWindowNanos,
67+
lastNanoTime: oldState.lastNanoTime,
68+
};
69+
}
70+
const nanoTimeDelta = currentNanoTime - oldState.lastNanoTime;
71+
const decayFactor = Math.exp(-nanoTimeDelta * this.inverseAdaptationTimeNanos);
72+
const currentEffectiveWindowCount = oldState.effectiveWindowCount * decayFactor + 1;
73+
const currentEffectiveWindowNanos = oldState.effectiveWindowNanos * decayFactor + nanoTimeDelta;
74+
return {
75+
effectiveWindowCount: currentEffectiveWindowCount,
76+
effectiveWindowNanos: currentEffectiveWindowNanos,
77+
lastNanoTime: currentNanoTime,
78+
};
79+
}
80+
81+
/**
82+
* Gets the current sample rate based on the effective window count and nanos.
83+
* This method calculates the sampling probability and returns it as a percentage.
84+
* If `roundToNearest` is true, it rounds down the sampling percentage to the nearest whole number.
85+
* @returns The current sample rate as a percentage.
86+
*/
87+
public getSampleRate(): number {
88+
const currentNanoTime = this.nanoTimeSupplier();
89+
this.state = this.updateState(this.state, currentNanoTime);
90+
91+
const samplingProbability =
92+
(this.state.effectiveWindowNanos * this.targetSpansPerNanosecondLimit) /
93+
this.state.effectiveWindowCount;
94+
let samplingPercentage = 100 * Math.min(samplingProbability, 1);
95+
96+
if (this.roundToNearest) {
97+
samplingPercentage = roundDownToNearest(samplingPercentage);
98+
}
99+
return samplingPercentage;
100+
}
101+
102+
/**
103+
* Checks whether span needs to be created and tracked.
104+
*
105+
* @param context - Parent Context which may contain a span.
106+
* @param traceId - traceId of the span to be created. It can be different from the
107+
* traceId in the {@link SpanContext}. Typically in situations when the
108+
* span to be created starts a new trace.
109+
* @param spanName - Name of the span to be created.
110+
* @param spanKind - Kind of the span to be created.
111+
* @param attributes - Initial set of SpanAttributes for the Span being constructed.
112+
* @param links - Collection of links that will be associated with the Span to
113+
* be created. Typically useful for batch operations.
114+
* @returns a {@link SamplingResult}.
115+
*/
116+
public shouldSample(
117+
context: Context,
118+
traceId: string,
119+
// @ts-expect-error unused argument
120+
spanName: string,
121+
// @ts-expect-error unused argument
122+
spanKind: SpanKind,
123+
attributes: Attributes,
124+
// @ts-expect-error unused argument
125+
links: Link[],
126+
): SamplingResult {
127+
const sampleRate = this.getSampleRate();
128+
return shouldSample(sampleRate, context, traceId, attributes)
129+
? { decision: SamplingDecision.RECORD_AND_SAMPLED, attributes: attributes }
130+
: { decision: SamplingDecision.NOT_RECORD, attributes: attributes };
131+
}
132+
133+
/**
134+
* Return Sampler description
135+
*/
136+
public toString(): string {
137+
return `RateLimitedSampler{${this.tracesPerSecond}}`;
138+
}
139+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import type { Attributes, Context } from "@opentelemetry/api";
5+
import { trace, TraceFlags } from "@opentelemetry/api";
6+
import { AzureMonitorSampleRate } from "../utils/constants/applicationinsights.js";
7+
8+
/**
9+
* Computes a deterministic hash code from a string input (typically a trace ID)
10+
* and returns a value between 0 and 100 for sampling decisions.
11+
*
12+
* This function replicates the C# hash algorithm used in Application Insights
13+
* to ensure consistent sampling decisions across different SDKs and languages.
14+
* The same trace ID will always produce the same hash value, enabling
15+
* distributed sampling where all spans in a trace are sampled consistently.
16+
*
17+
* @param input - The input string to hash (usually a trace ID)
18+
* @returns A number between 0 and 100 representing the hash-based sampling score
19+
*/
20+
export function getSamplingHashCode(input: string): number {
21+
const csharpMin = -2147483648;
22+
const csharpMax = 2147483647;
23+
let hash = 5381;
24+
25+
if (!input) {
26+
return 0;
27+
}
28+
29+
// Ensure input is at least 8 characters long by repeating it
30+
let processedInput = input;
31+
while (processedInput.length < 8) {
32+
processedInput = processedInput + processedInput;
33+
}
34+
35+
// Compute hash using a variation of djb2 algorithm with C# integer overflow simulation
36+
// This uses hash * 33 + c (where hash << 5 + hash equals hash * 33)
37+
for (let i = 0; i < processedInput.length; i++) {
38+
// JS doesn't respond to integer overflow by wrapping around. Simulate it with bitwise operators ( | 0)
39+
hash = ((((hash << 5) + hash) | 0) + processedInput.charCodeAt(i)) | 0;
40+
}
41+
42+
// Normalize hash to positive value and convert to 0-100 range
43+
hash = hash <= csharpMin ? csharpMax : Math.abs(hash);
44+
return (hash / csharpMax) * 100;
45+
}
46+
47+
export function roundDownToNearest(samplingPercentage: number): number {
48+
if (samplingPercentage === 0) {
49+
return 0;
50+
}
51+
const itemCount = 100 / samplingPercentage;
52+
return 100.0 / Math.ceil(itemCount);
53+
}
54+
55+
export function shouldSample(
56+
samplePercentage: number,
57+
context: Context,
58+
traceId: string,
59+
attributes: Attributes,
60+
): boolean {
61+
let sampleRate = samplePercentage;
62+
let isSampled = undefined;
63+
64+
if (sampleRate === 100) {
65+
isSampled = true;
66+
} else if (sampleRate === 0) {
67+
isSampled = false;
68+
} else {
69+
// Try to get the parent sampling result first
70+
const parentSpan = trace.getSpan(context);
71+
const parentSpanContext = parentSpan?.spanContext();
72+
if (
73+
parentSpanContext &&
74+
trace.isSpanContextValid(parentSpanContext) &&
75+
!parentSpanContext.isRemote
76+
) {
77+
if ((parentSpanContext.traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED) {
78+
isSampled = true;
79+
} else if ((parentSpanContext.traceFlags & TraceFlags.NONE) === TraceFlags.NONE) {
80+
isSampled = false;
81+
}
82+
// If the parent span is valid and not remote, we can use its sample rate
83+
const parentSampleRate = Number((parentSpan as any).attributes?.[AzureMonitorSampleRate]);
84+
if (!isNaN(parentSampleRate)) {
85+
sampleRate = Number(parentSampleRate);
86+
}
87+
}
88+
}
89+
90+
// Only add sample rate attribute if it's not 100
91+
if (sampleRate !== 100) {
92+
// Add sample rate as span attribute
93+
attributes = attributes || {};
94+
attributes[AzureMonitorSampleRate] = sampleRate;
95+
}
96+
97+
if (isSampled === undefined) {
98+
const samplingHashCode = getSamplingHashCode(traceId);
99+
return samplingHashCode < sampleRate;
100+
} else {
101+
return isSampled;
102+
}
103+
}

sdk/monitor/monitor-opentelemetry-exporter/src/utils/metricUtils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import type {
1515
MetricDataPoint,
1616
} from "../generated/index.js";
1717
import { createTagsFromResource } from "./common.js";
18-
import { BreezePerformanceCounterNames, OTelPerformanceCounterNames, Tags } from "../types.js";
18+
import type { Tags } from "../types.js";
19+
import { BreezePerformanceCounterNames, OTelPerformanceCounterNames } from "../types.js";
1920
import {
2021
ENV_OTEL_METRICS_EXPORTER,
2122
ENV_OTLP_METRICS_ENDPOINT,

0 commit comments

Comments
 (0)