Skip to content

Commit

Permalink
fix(node-experimental): Sample in OTEL Sampler (#9203)
Browse files Browse the repository at this point in the history
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
mydea authored Oct 11, 2023
1 parent a173fa2 commit 5fd5033
Show file tree
Hide file tree
Showing 15 changed files with 561 additions and 119 deletions.
1 change: 1 addition & 0 deletions packages/node-experimental/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export const OTEL_ATTR_BREADCRUMB_LEVEL = 'sentry.breadcrumb.level';
export const OTEL_ATTR_BREADCRUMB_EVENT_ID = 'sentry.breadcrumb.event_id';
export const OTEL_ATTR_BREADCRUMB_CATEGORY = 'sentry.breadcrumb.category';
export const OTEL_ATTR_BREADCRUMB_DATA = 'sentry.breadcrumb.data';
export const OTEL_ATTR_SENTRY_SAMPLE_RATE = 'sentry.sample_rate';
190 changes: 190 additions & 0 deletions packages/node-experimental/src/opentelemetry/sampler.ts
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;
}
11 changes: 10 additions & 1 deletion packages/node-experimental/src/opentelemetry/spanExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import { mapOtelStatus, parseOtelSpanDescription } from '@sentry/opentelemetry-n
import type { DynamicSamplingContext, Span as SentrySpan, SpanOrigin, TransactionSource } from '@sentry/types';
import { logger } from '@sentry/utils';

import { OTEL_ATTR_OP, OTEL_ATTR_ORIGIN, OTEL_ATTR_PARENT_SAMPLED, OTEL_ATTR_SOURCE } from '../constants';
import {
OTEL_ATTR_OP,
OTEL_ATTR_ORIGIN,
OTEL_ATTR_PARENT_SAMPLED,
OTEL_ATTR_SENTRY_SAMPLE_RATE,
OTEL_ATTR_SOURCE,
} from '../constants';
import { getCurrentHub } from '../sdk/hub';
import { NodeExperimentalScope } from '../sdk/scope';
import type { NodeExperimentalTransaction } from '../sdk/transaction';
Expand Down Expand Up @@ -172,11 +178,13 @@ function createTransactionForOtelSpan(span: ReadableSpan): NodeExperimentalTrans
metadata: {
dynamicSamplingContext,
source,
sampleRate: span.attributes[OTEL_ATTR_SENTRY_SAMPLE_RATE] as number | undefined,
...metadata,
},
data: removeSentryAttributes(data),
origin,
tags,
sampled: true,
}) as NodeExperimentalTransaction;

transaction.setContext('otel', {
Expand Down Expand Up @@ -270,6 +278,7 @@ function removeSentryAttributes(data: Record<string, unknown>): Record<string, u
delete cleanedData[OTEL_ATTR_ORIGIN];
delete cleanedData[OTEL_ATTR_OP];
delete cleanedData[OTEL_ATTR_SOURCE];
delete cleanedData[OTEL_ATTR_SENTRY_SAMPLE_RATE];
/* eslint-enable @typescript-eslint/no-dynamic-delete */

return cleanedData;
Expand Down
31 changes: 7 additions & 24 deletions packages/node-experimental/src/opentelemetry/spanProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@ import { ROOT_CONTEXT, SpanKind, trace } from '@opentelemetry/api';
import type { Span, SpanProcessor as SpanProcessorInterface } from '@opentelemetry/sdk-trace-base';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
import {
_INTERNAL_SENTRY_TRACE_PARENT_CONTEXT_KEY,
maybeCaptureExceptionForTimedEvent,
} from '@sentry/opentelemetry-node';
import type { Hub, TraceparentData } from '@sentry/types';
import { maybeCaptureExceptionForTimedEvent } from '@sentry/opentelemetry-node';
import type { Hub } from '@sentry/types';
import { logger } from '@sentry/utils';

import { OTEL_ATTR_PARENT_SAMPLED, OTEL_CONTEXT_HUB_KEY } from '../constants';
import { OTEL_CONTEXT_HUB_KEY } from '../constants';
import { Http } from '../integrations';
import type { NodeExperimentalClient } from '../sdk/client';
import { getCurrentHub } from '../sdk/hub';
Expand Down Expand Up @@ -51,17 +49,15 @@ export class SentrySpanProcessor extends BatchSpanProcessor implements SpanProce
setSpanHub(span, actualHub);
}

// We need to set this here based on the parent context
const parentSampled = getParentSampled(span, parentContext);
if (typeof parentSampled === 'boolean') {
span.setAttribute(OTEL_ATTR_PARENT_SAMPLED, parentSampled);
}
__DEBUG_BUILD__ && logger.log(`[Tracing] Starting span "${span.name}" (${span.spanContext().spanId})`);

return super.onStart(span, parentContext);
}

/** @inheritDoc */
public onEnd(span: Span): void {
__DEBUG_BUILD__ && logger.log(`[Tracing] Finishing span "${span.name}" (${span.spanContext().spanId})`);

if (!shouldCaptureSentrySpan(span)) {
// Prevent this being called to super.onEnd(), which would pass this to the span exporter
return;
Expand All @@ -77,19 +73,6 @@ export class SentrySpanProcessor extends BatchSpanProcessor implements SpanProce
}
}

function getTraceParentData(parentContext: Context): TraceparentData | undefined {
return parentContext.getValue(_INTERNAL_SENTRY_TRACE_PARENT_CONTEXT_KEY) as TraceparentData | undefined;
}

function getParentSampled(span: Span, parentContext: Context): boolean | undefined {
const spanContext = span.spanContext();
const traceId = spanContext.traceId;
const traceparentData = getTraceParentData(parentContext);

// Only inherit sample rate if `traceId` is the same
return traceparentData && traceId === traceparentData.traceId ? traceparentData.parentSampled : undefined;
}

function shouldCaptureSentrySpan(span: Span): boolean {
const client = getCurrentHub().getClient<NodeExperimentalClient>();
const httpIntegration = client ? client.getIntegration(Http) : undefined;
Expand Down
27 changes: 16 additions & 11 deletions packages/node-experimental/src/sdk/initOtel.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { diag, DiagLogLevel } from '@opentelemetry/api';
import { Resource } from '@opentelemetry/resources';
import { AlwaysOnSampler, BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import { SDK_VERSION } from '@sentry/core';
import { SentryPropagator } from '@sentry/opentelemetry-node';
import { logger } from '@sentry/utils';

import { SentrySampler } from '../opentelemetry/sampler';
import { SentrySpanProcessor } from '../opentelemetry/spanProcessor';
import type { NodeExperimentalClient } from '../types';
import { setupEventContextTrace } from '../utils/setupEventContextTrace';
Expand All @@ -19,7 +20,15 @@ import { getCurrentHub } from './hub';
export function initOtel(): void {
const client = getCurrentHub().getClient<NodeExperimentalClient>();

if (client?.getOptions().debug) {
if (!client) {
__DEBUG_BUILD__ &&
logger.warn(
'No client available, skipping OpenTelemetry setup. This probably means that `Sentry.init()` was not called before `initOtel()`.',
);
return;
}

if (client.getOptions().debug) {
const otelLogger = new Proxy(logger as typeof logger & { verbose: (typeof logger)['debug'] }, {
get(target, prop, receiver) {
const actualProp = prop === 'verbose' ? 'debug' : prop;
Expand All @@ -30,21 +39,17 @@ export function initOtel(): void {
diag.setLogger(otelLogger, DiagLogLevel.DEBUG);
}

if (client) {
setupEventContextTrace(client);
}
setupEventContextTrace(client);

const provider = setupOtel();
if (client) {
client.traceProvider = provider;
}
const provider = setupOtel(client);
client.traceProvider = provider;
}

/** Just exported for tests. */
export function setupOtel(): BasicTracerProvider {
export function setupOtel(client: NodeExperimentalClient): BasicTracerProvider {
// Create and configure NodeTracerProvider
const provider = new BasicTracerProvider({
sampler: new AlwaysOnSampler(),
sampler: new SentrySampler(client),
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'node-experimental',
[SemanticResourceAttributes.SERVICE_NAMESPACE]: 'sentry',
Expand Down
30 changes: 8 additions & 22 deletions packages/node-experimental/src/sdk/transaction.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,21 @@
import type { Hub } from '@sentry/core';
import { sampleTransaction, Transaction } from '@sentry/core';
import type {
ClientOptions,
CustomSamplingContext,
Hub as HubInterface,
Scope,
TransactionContext,
} from '@sentry/types';
import { Transaction } from '@sentry/core';
import type { ClientOptions, Hub as HubInterface, Scope, TransactionContext } from '@sentry/types';
import { uuid4 } from '@sentry/utils';

/**
* This is a fork of core's tracing/hubextensions.ts _startTransaction,
* with some OTEL specifics.
*/
export function startTransaction(
hub: HubInterface,
transactionContext: TransactionContext,
customSamplingContext?: CustomSamplingContext,
): Transaction {
export function startTransaction(hub: HubInterface, transactionContext: TransactionContext): Transaction {
const client = hub.getClient();
const options: Partial<ClientOptions> = (client && client.getOptions()) || {};

let transaction = new NodeExperimentalTransaction(transactionContext, hub as Hub);
transaction = sampleTransaction(transaction, options, {
parentSampled: transactionContext.parentSampled,
transactionContext,
...customSamplingContext,
});
if (transaction.sampled) {
transaction.initSpanRecorder(options._experiments && (options._experiments.maxSpans as number));
}
const transaction = new NodeExperimentalTransaction(transactionContext, hub as Hub);
// Since we do not do sampling here, we assume that this is _always_ sampled
// Any sampling decision happens in OpenTelemetry's sampler
transaction.initSpanRecorder(options._experiments && (options._experiments.maxSpans as number));

if (client && client.emit) {
client.emit('startTransaction', transaction);
}
Expand Down
Loading

0 comments on commit 5fd5033

Please sign in to comment.