-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(node-experimental): Sample in OTEL Sampler (#9203)
In order for sampling propagation to work, we need to sample in OTEL, not in Sentry. As the sentry spans are only created later. Note that this _does not_ fix propagation yet in node-experimental, once this is merged I still need to adjust the propagator (or rather, fork it) from opentelemetry-node to work without sentry spans.
- Loading branch information
Showing
15 changed files
with
561 additions
and
119 deletions.
There are no files selected for viewing
This file contains 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
190 changes: 190 additions & 0 deletions
190
packages/node-experimental/src/opentelemetry/sampler.ts
This file contains 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,190 @@ | ||
/* eslint-disable no-bitwise */ | ||
import type { Attributes, Context, SpanContext } from '@opentelemetry/api'; | ||
import { isSpanContextValid, trace, TraceFlags } from '@opentelemetry/api'; | ||
import type { Sampler, SamplingResult } from '@opentelemetry/sdk-trace-base'; | ||
import { SamplingDecision } from '@opentelemetry/sdk-trace-base'; | ||
import { hasTracingEnabled } from '@sentry/core'; | ||
import { _INTERNAL_SENTRY_TRACE_PARENT_CONTEXT_KEY } from '@sentry/opentelemetry-node'; | ||
import type { Client, ClientOptions, SamplingContext, TraceparentData } from '@sentry/types'; | ||
import { isNaN, logger } from '@sentry/utils'; | ||
|
||
import { OTEL_ATTR_PARENT_SAMPLED, OTEL_ATTR_SENTRY_SAMPLE_RATE } from '../constants'; | ||
|
||
/** | ||
* A custom OTEL sampler that uses Sentry sampling rates to make it's decision | ||
*/ | ||
export class SentrySampler implements Sampler { | ||
private _client: Client; | ||
|
||
public constructor(client: Client) { | ||
this._client = client; | ||
} | ||
|
||
/** @inheritDoc */ | ||
public shouldSample( | ||
context: Context, | ||
traceId: string, | ||
spanName: string, | ||
_spanKind: unknown, | ||
_attributes: unknown, | ||
_links: unknown, | ||
): SamplingResult { | ||
const options = this._client.getOptions(); | ||
|
||
if (!hasTracingEnabled(options)) { | ||
return { decision: SamplingDecision.NOT_RECORD }; | ||
} | ||
|
||
const parentContext = trace.getSpanContext(context); | ||
|
||
let parentSampled: boolean | undefined = undefined; | ||
|
||
// Only inherit sample rate if `traceId` is the same | ||
// Note for testing: `isSpanContextValid()` checks the format of the traceId/spanId, so we need to pass valid ones | ||
if (parentContext && isSpanContextValid(parentContext) && parentContext.traceId === traceId) { | ||
if (parentContext.isRemote) { | ||
parentSampled = getParentRemoteSampled(parentContext, context); | ||
__DEBUG_BUILD__ && | ||
logger.log(`[Tracing] Inheriting remote parent's sampled decision for ${spanName}: ${parentSampled}`); | ||
} else { | ||
parentSampled = Boolean(parentContext.traceFlags & TraceFlags.SAMPLED); | ||
__DEBUG_BUILD__ && | ||
logger.log(`[Tracing] Inheriting parent's sampled decision for ${spanName}: ${parentSampled}`); | ||
} | ||
} | ||
|
||
const sampleRate = getSampleRate(options, { | ||
transactionContext: { | ||
name: spanName, | ||
parentSampled, | ||
}, | ||
parentSampled, | ||
}); | ||
|
||
const attributes: Attributes = { | ||
[OTEL_ATTR_SENTRY_SAMPLE_RATE]: Number(sampleRate), | ||
}; | ||
|
||
if (typeof parentSampled === 'boolean') { | ||
attributes[OTEL_ATTR_PARENT_SAMPLED] = parentSampled; | ||
} | ||
|
||
// Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The | ||
// only valid values are booleans or numbers between 0 and 1.) | ||
if (!isValidSampleRate(sampleRate)) { | ||
__DEBUG_BUILD__ && logger.warn('[Tracing] Discarding span because of invalid sample rate.'); | ||
|
||
return { | ||
decision: SamplingDecision.NOT_RECORD, | ||
attributes, | ||
}; | ||
} | ||
|
||
// if the function returned 0 (or false), or if `tracesSampleRate` is 0, it's a sign the transaction should be dropped | ||
if (!sampleRate) { | ||
__DEBUG_BUILD__ && | ||
logger.log( | ||
`[Tracing] Discarding span because ${ | ||
typeof options.tracesSampler === 'function' | ||
? 'tracesSampler returned 0 or false' | ||
: 'a negative sampling decision was inherited or tracesSampleRate is set to 0' | ||
}`, | ||
); | ||
|
||
return { | ||
decision: SamplingDecision.NOT_RECORD, | ||
attributes, | ||
}; | ||
} | ||
|
||
// Now we roll the dice. Math.random is inclusive of 0, but not of 1, so strict < is safe here. In case sampleRate is | ||
// a boolean, the < comparison will cause it to be automatically cast to 1 if it's true and 0 if it's false. | ||
const isSampled = Math.random() < (sampleRate as number | boolean); | ||
|
||
// if we're not going to keep it, we're done | ||
if (!isSampled) { | ||
__DEBUG_BUILD__ && | ||
logger.log( | ||
`[Tracing] Discarding span because it's not included in the random sample (sampling rate = ${Number( | ||
sampleRate, | ||
)})`, | ||
); | ||
|
||
return { | ||
decision: SamplingDecision.NOT_RECORD, | ||
attributes, | ||
}; | ||
} | ||
|
||
return { | ||
decision: SamplingDecision.RECORD_AND_SAMPLED, | ||
attributes, | ||
}; | ||
} | ||
|
||
/** Returns the sampler name or short description with the configuration. */ | ||
public toString(): string { | ||
return 'SentrySampler'; | ||
} | ||
} | ||
|
||
function getSampleRate( | ||
options: Pick<ClientOptions, 'tracesSampleRate' | 'tracesSampler' | 'enableTracing'>, | ||
samplingContext: SamplingContext, | ||
): number | boolean { | ||
if (typeof options.tracesSampler === 'function') { | ||
return options.tracesSampler(samplingContext); | ||
} | ||
|
||
if (samplingContext.parentSampled !== undefined) { | ||
return samplingContext.parentSampled; | ||
} | ||
|
||
if (typeof options.tracesSampleRate !== 'undefined') { | ||
return options.tracesSampleRate; | ||
} | ||
|
||
// When `enableTracing === true`, we use a sample rate of 100% | ||
if (options.enableTracing) { | ||
return 1; | ||
} | ||
|
||
return 0; | ||
} | ||
|
||
/** | ||
* Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1). | ||
*/ | ||
function isValidSampleRate(rate: unknown): boolean { | ||
// we need to check NaN explicitly because it's of type 'number' and therefore wouldn't get caught by this typecheck | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
if (isNaN(rate) || !(typeof rate === 'number' || typeof rate === 'boolean')) { | ||
__DEBUG_BUILD__ && | ||
logger.warn( | ||
`[Tracing] Given sample rate is invalid. Sample rate must be a boolean or a number between 0 and 1. Got ${JSON.stringify( | ||
rate, | ||
)} of type ${JSON.stringify(typeof rate)}.`, | ||
); | ||
return false; | ||
} | ||
|
||
// in case sampleRate is a boolean, it will get automatically cast to 1 if it's true and 0 if it's false | ||
if (rate < 0 || rate > 1) { | ||
__DEBUG_BUILD__ && | ||
logger.warn(`[Tracing] Given sample rate is invalid. Sample rate must be between 0 and 1. Got ${rate}.`); | ||
return false; | ||
} | ||
return true; | ||
} | ||
|
||
function getTraceParentData(parentContext: Context): TraceparentData | undefined { | ||
return parentContext.getValue(_INTERNAL_SENTRY_TRACE_PARENT_CONTEXT_KEY) as TraceparentData | undefined; | ||
} | ||
|
||
function getParentRemoteSampled(spanContext: SpanContext, context: Context): boolean | undefined { | ||
const traceId = spanContext.traceId; | ||
const traceparentData = getTraceParentData(context); | ||
|
||
// Only inherit sample rate if `traceId` is the same | ||
return traceparentData && traceId === traceparentData.traceId ? traceparentData.parentSampled : undefined; | ||
} |
This file contains 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 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 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 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.