Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ module.exports = [
gzip: true,
limit: '40.7 KB',
},
{
name: '@sentry/browser (incl. Tracing with Span Streaming)',
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'spanStreamingIntegration'),
gzip: true,
limit: '41.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay)',
path: 'packages/browser/build/npm/esm/index.js',
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,4 @@ export { unleashIntegration } from './integrations/featureFlags/unleash';
export { statsigIntegration } from './integrations/featureFlags/statsig';
export { diagnoseSdkConnectivity } from './diagnose-sdk';
export { webWorkerIntegration, registerWebWorker } from './integrations/webWorker';
export { spanStreamingIntegration } from './integrations/spanstreaming';
283 changes: 283 additions & 0 deletions packages/browser/src/integrations/spanstreaming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
import type { Client, IntegrationFn, Scope, ScopeData, Span, SpanAttributes, SpanV2JSON } from '@sentry/core';
import {
attributesFromObject,
createSpanV2Envelope,
debug,
defineIntegration,
getCapturedScopesOnSpan,
getDynamicSamplingContextFromSpan,
getGlobalScope,
getRootSpan as getSegmentSpan,
httpHeadersToSpanAttributes,
isV2BeforeSendSpanCallback,
mergeScopeData,
reparentChildSpans,
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT,
SEMANTIC_ATTRIBUTE_SENTRY_RELEASE,
SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME,
SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION,
SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME,
SEMANTIC_ATTRIBUTE_URL_FULL,
SEMANTIC_ATTRIBUTE_USER_EMAIL,
SEMANTIC_ATTRIBUTE_USER_ID,
SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS,
SEMANTIC_ATTRIBUTE_USER_USERNAME,
shouldIgnoreSpan,
showSpanDropWarning,
spanToV2JSON,
} from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { getHttpRequestData } from '../helpers';

export interface SpanStreamingOptions {
batchLimit: number;
}

export const spanStreamingIntegration = defineIntegration(((userOptions?: Partial<SpanStreamingOptions>) => {
const validatedUserProvidedBatchLimit =
userOptions?.batchLimit && userOptions.batchLimit <= 1000 && userOptions.batchLimit >= 1
? userOptions.batchLimit
: undefined;

if (DEBUG_BUILD && userOptions?.batchLimit && !validatedUserProvidedBatchLimit) {
debug.warn('SpanStreaming batchLimit must be between 1 and 1000, defaulting to 1000');
}

const options: SpanStreamingOptions = {
...userOptions,
batchLimit:
userOptions?.batchLimit && userOptions.batchLimit <= 1000 && userOptions.batchLimit >= 1
? userOptions.batchLimit
: 1000,
};

// key: traceId-segmentSpanId
const spanTreeMap = new Map<string, Set<Span>>();

return {
name: 'SpanStreaming',
setup(client) {
const clientOptions = client.getOptions();
const beforeSendSpan = clientOptions.beforeSendSpan;

const initialMessage = 'spanStreamingIntegration requires';
const fallbackMsg = 'Falling back to static trace lifecycle.';

if (clientOptions.traceLifecycle !== 'stream') {
DEBUG_BUILD && debug.warn(`${initialMessage} \`traceLifecycle\` to be set to "stream"! ${fallbackMsg}`);
return;
}

if (beforeSendSpan && !isV2BeforeSendSpanCallback(beforeSendSpan)) {
client.getOptions().traceLifecycle = 'static';
debug.warn(`${initialMessage} a beforeSendSpan callback using \`makeV2Callback\`! ${fallbackMsg}`);
return;
}

client.on('spanEnd', span => {
const spanTreeMapKey = getSpanTreeMapKey(span);
const spanBuffer = spanTreeMap.get(spanTreeMapKey);
if (spanBuffer) {
spanBuffer.add(span);
} else {
spanTreeMap.set(spanTreeMapKey, new Set([span]));
}
});

// For now, we send all spans on local segment (root) span end.
// TODO: This will change once we have more concrete ideas about a universal SDK data buffer.
client.on('segmentSpanEnd', segmentSpan => {
processAndSendSpans(segmentSpan, {
spanTreeMap: spanTreeMap,
client,
batchLimit: options.batchLimit,
beforeSendSpan,
});
});
},
};
}) satisfies IntegrationFn);

interface SpanProcessingOptions {
client: Client;
spanTreeMap: Map<string, Set<Span>>;
batchLimit: number;
beforeSendSpan: ((span: SpanV2JSON) => SpanV2JSON) | undefined;
}

/**
* Just the traceid alone isn't enough because there can be multiple span trees with the same traceid.
*/
function getSpanTreeMapKey(span: Span): string {
return `${span.spanContext().traceId}-${getSegmentSpan(span).spanContext().spanId}`;
}

function processAndSendSpans(
segmentSpan: Span,
{ client, spanTreeMap, batchLimit, beforeSendSpan }: SpanProcessingOptions,
): void {
const traceId = segmentSpan.spanContext().traceId;
const spanTreeMapKey = getSpanTreeMapKey(segmentSpan);
const spansOfTrace = spanTreeMap.get(spanTreeMapKey);

if (!spansOfTrace?.size) {
spanTreeMap.delete(spanTreeMapKey);
return;
}

const segmentSpanJson = spanToV2JSON(segmentSpan);

for (const span of spansOfTrace) {
applyCommonSpanAttributes(span, segmentSpanJson, client);
}

applyScopeToSegmentSpan(segmentSpan, segmentSpanJson, client);

// TODO: Apply scope data and contexts to segment span

const { ignoreSpans } = client.getOptions();

// 1. Check if the entire span tree is ignored by ignoreSpans
if (ignoreSpans?.length && shouldIgnoreSpan(segmentSpanJson, ignoreSpans)) {
client.recordDroppedEvent('before_send', 'span', spansOfTrace.size);
spanTreeMap.delete(spanTreeMapKey);
return;
}

const serializedSpans = Array.from(spansOfTrace ?? []).map(s => {
const serialized = spanToV2JSON(s);
// remove internal span attributes we don't need to send.
delete serialized.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME];
return serialized;
});

const processedSpans = [];
let ignoredSpanCount = 0;

for (const span of serializedSpans) {
// 2. Check if child spans should be ignored
const isChildSpan = span.span_id !== segmentSpan.spanContext().spanId;
if (ignoreSpans?.length && isChildSpan && shouldIgnoreSpan(span, ignoreSpans)) {
reparentChildSpans(serializedSpans, span);
ignoredSpanCount++;
// drop this span by not adding it to the processedSpans array
continue;
}

// 3. Apply beforeSendSpan callback
const processedSpan = beforeSendSpan ? applyBeforeSendSpanCallback(span, beforeSendSpan) : span;
processedSpans.push(processedSpan);
}

if (ignoredSpanCount) {
client.recordDroppedEvent('before_send', 'span', ignoredSpanCount);
}

const batches: SpanV2JSON[][] = [];
for (let i = 0; i < processedSpans.length; i += batchLimit) {
batches.push(processedSpans.slice(i, i + batchLimit));
}

DEBUG_BUILD && debug.log(`Sending trace ${traceId} in ${batches.length} batch${batches.length === 1 ? '' : 'es'}`);

const dsc = getDynamicSamplingContextFromSpan(segmentSpan);

for (const batch of batches) {
const envelope = createSpanV2Envelope(batch, dsc, client);
// no need to handle client reports for network errors,
// buffer overflows or rate limiting here. All of this is handled
// by client and transport.
client.sendEnvelope(envelope).then(null, reason => {
DEBUG_BUILD && debug.error('Error while sending span stream envelope:', reason);
});
}

spanTreeMap.delete(spanTreeMapKey);
}

function applyCommonSpanAttributes(span: Span, serializedSegmentSpan: SpanV2JSON, client: Client): void {
const sdk = client.getSdkMetadata();
const { release, environment, sendDefaultPii } = client.getOptions();

const { isolationScope: spanIsolationScope, scope: spanScope } = getCapturedScopesOnSpan(span);

const originalAttributeKeys = Object.keys(spanToV2JSON(span).attributes ?? {});

const finalScopeData = getFinalScopeData(spanIsolationScope, spanScope);

// avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation)
setAttributesIfNotPresent(span, originalAttributeKeys, {
[SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: release,
[SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: environment,
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: serializedSegmentSpan.name,
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: sdk?.sdk?.name,
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: sdk?.sdk?.version,
...(sendDefaultPii
? {
[SEMANTIC_ATTRIBUTE_USER_ID]: finalScopeData.user?.id,
[SEMANTIC_ATTRIBUTE_USER_EMAIL]: finalScopeData.user?.email,
[SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: finalScopeData.user?.ip_address ?? undefined,
[SEMANTIC_ATTRIBUTE_USER_USERNAME]: finalScopeData.user?.username,
}
: {}),
});
}

/**
* Adds span attributes frome
*/
function applyScopeToSegmentSpan(segmentSpan: Span, serializedSegmentSpan: SpanV2JSON, client: Client): void {
const { isolationScope, scope } = getCapturedScopesOnSpan(segmentSpan);
const finalScopeData = getFinalScopeData(isolationScope, scope);

const browserRequestData = getHttpRequestData();

const tags = finalScopeData.tags ?? {};

let contextAttributes = {};
Object.keys(finalScopeData.contexts).forEach(key => {
if (finalScopeData.contexts[key]) {
contextAttributes = { ...contextAttributes, ...attributesFromObject(finalScopeData.contexts[key]) };
}
});

const extraAttributes = attributesFromObject(finalScopeData.extra);

setAttributesIfNotPresent(segmentSpan, Object.keys(serializedSegmentSpan.attributes ?? {}), {
[SEMANTIC_ATTRIBUTE_URL_FULL]: browserRequestData.url,
...httpHeadersToSpanAttributes(browserRequestData.headers, client.getOptions().sendDefaultPii ?? false),
...tags,
...contextAttributes,
...extraAttributes,
});
}

function applyBeforeSendSpanCallback(span: SpanV2JSON, beforeSendSpan: (span: SpanV2JSON) => SpanV2JSON): SpanV2JSON {
const modifedSpan = beforeSendSpan(span);
if (!modifedSpan) {
showSpanDropWarning();
return span;
}
return modifedSpan;
}

function setAttributesIfNotPresent(span: Span, originalAttributeKeys: string[], newAttributes: SpanAttributes): void {
Object.keys(newAttributes).forEach(key => {
if (!originalAttributeKeys.includes(key)) {
span.setAttribute(key, newAttributes[key]);
}
});
}

// TODO: Extract this to a helper in core. It's used in multiple places.
function getFinalScopeData(isolationScope: Scope | undefined, scope: Scope | undefined): ScopeData {
const finalScopeData = getGlobalScope().getScopeData();
if (isolationScope) {
mergeScopeData(finalScopeData, isolationScope.getScopeData());
}
if (scope) {
mergeScopeData(finalScopeData, scope.getScopeData());
}
return finalScopeData;
}
18 changes: 17 additions & 1 deletion packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type { SeverityLevel } from './types-hoist/severity';
import type { Span, SpanAttributes, SpanContextData, SpanJSON } from './types-hoist/span';
import type { StartSpanOptions } from './types-hoist/startSpanOptions';
import type { Transport, TransportMakeRequestResponse } from './types-hoist/transport';
import { isV2BeforeSendSpanCallback } from './utils/beforeSendSpan';
import { createClientReportEnvelope } from './utils/clientreport';
import { debug } from './utils/debug-logger';
import { dsnToString, makeDsn } from './utils/dsn';
Expand Down Expand Up @@ -509,6 +510,14 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
*/
public on(hook: 'spanEnd', callback: (span: Span) => void): () => void;

/**
* Register a callback for after a span is ended.
* NOTE: The span cannot be mutated anymore in this callback.
* Receives the span as argument.
* @returns {() => void} A function that, when executed, removes the registered callback.
*/
public on(hook: 'segmentSpanEnd', callback: (span: Span) => void): () => void;

/**
* Register a callback for when an idle span is allowed to auto-finish.
* @returns {() => void} A function that, when executed, removes the registered callback.
Expand Down Expand Up @@ -742,6 +751,9 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
/** Fire a hook whenever a span ends. */
public emit(hook: 'spanEnd', span: Span): void;

/** Fire a hook whenever a segment span ends. */
public emit(hook: 'segmentSpanEnd', span: Span): void;

/**
* Fire a hook indicating that an idle span is allowed to auto finish.
*/
Expand Down Expand Up @@ -1316,13 +1328,17 @@ function _validateBeforeSendResult(
/**
* Process the matching `beforeSendXXX` callback.
*/
// eslint-disable-next-line complexity
function processBeforeSend(
client: Client,
options: ClientOptions,
event: Event,
hint: EventHint,
): PromiseLike<Event | null> | Event | null {
const { beforeSend, beforeSendTransaction, beforeSendSpan, ignoreSpans } = options;
const { beforeSend, beforeSendTransaction, ignoreSpans } = options;

const beforeSendSpan = !isV2BeforeSendSpanCallback(options.beforeSendSpan) && options.beforeSendSpan;

let processedEvent = event;

if (isErrorEvent(processedEvent) && beforeSend) {
Expand Down
Loading
Loading