Merged
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
8 changes: 5 additions & 3 deletions packages/core/src/utils/spanOnScope.ts
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
import type { Scope } from '../scope';
import type { Span } from '../types/span';
import { addNonEnumerableProperty } from '../utils/object';
import { derefWeakRef, makeWeakRef, type MaybeWeakRef } from './weakRef';

const SCOPE_SPAN_FIELD = '_sentrySpan';

type ScopeWithMaybeSpan = Scope & {
[SCOPE_SPAN_FIELD]?: Span;
[SCOPE_SPAN_FIELD]?: MaybeWeakRef<Span>;
};

/**
Expand All@@ -14,7 +15,8 @@ type ScopeWithMaybeSpan = Scope & {
*/
export function _setSpanForScope(scope: Scope, span: Span | undefined): void {
if (span) {
addNonEnumerableProperty(scope as ScopeWithMaybeSpan, SCOPE_SPAN_FIELD, span);
// Use WeakRef to avoid circular reference with span holding scope
addNonEnumerableProperty(scope as ScopeWithMaybeSpan, SCOPE_SPAN_FIELD, makeWeakRef(span));
} else {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete (scope as ScopeWithMaybeSpan)[SCOPE_SPAN_FIELD];
Expand All@@ -26,5 +28,5 @@ export function _setSpanForScope(scope: Scope, span: Span | undefined): void {
* NOTE: This should NOT be used directly, but is only used internally by the trace methods.
*/
export function _getSpanForScope(scope: ScopeWithMaybeSpan): Span | undefined {
return scope[SCOPE_SPAN_FIELD];
return derefWeakRef(scope[SCOPE_SPAN_FIELD]);
}
37 changes: 13 additions & 24 deletions packages/core/test/lib/tracing/utils.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,17 +44,16 @@ describe('tracing utils', () => {
expect(retrieved.isolationScope).toBeUndefined();
});

it('uses WeakRef only for isolation scopes', () => {
it('uses WeakRef only for isolation scope, stores scope directly', () => {
const span = createMockSpan();
const scope = new Scope();
const isolationScope = new Scope();

setCapturedScopesOnSpan(span, scope, isolationScope);

// Check that only isolation scope is wrapped with WeakRef
const spanWithScopes = span as any;
expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope stored directly
expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef); // Isolation scope wrapped
expect(spanWithScopes._sentryScope).toBe(scope);
expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef);

// Verify we can still retrieve the scopes
const retrieved = getCapturedScopesOnSpan(span);
Expand All@@ -77,14 +76,8 @@ describe('tracing utils', () => {

// Check that both scopes are stored directly when WeakRef is not available
const spanWithScopes = span as any;
expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope always stored directly
expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope); // Isolation scope falls back to direct storage

// When WeakRef is available, ensure regular scope is not wrapped but isolation scope would be
if (originalWeakRef) {
expect(spanWithScopes._sentryScope).not.toBeInstanceOf(originalWeakRef);
expect(spanWithScopes._sentryIsolationScope).not.toBeInstanceOf(originalWeakRef);
}
expect(spanWithScopes._sentryScope).toBe(scope);
expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope);

// Verify we can still retrieve the scopes
const retrieved = getCapturedScopesOnSpan(span);
Expand All@@ -96,46 +89,42 @@ describe('tracing utils', () => {
}
});

it('handles WeakRef deref returning undefined gracefully', () => {
it('handles WeakRef deref returning undefined gracefully for isolation scope', () => {
const span = createMockSpan();
const scope = new Scope();
const isolationScope = new Scope();

setCapturedScopesOnSpan(span, scope, isolationScope);

// Mock WeakRef.deref to return undefined for isolation scope (simulating garbage collection)
// Regular scope is stored directly, so it should always be available
// Mock isolation scope WeakRef to return undefined (simulating garbage collection)
const spanWithScopes = span as any;
const mockIsolationScopeWeakRef = {
deref: vi.fn().mockReturnValue(undefined),
};

// Keep the regular scope as is (stored directly)
// Only replace the isolation scope with a mock WeakRef
spanWithScopes._sentryIsolationScope = mockIsolationScopeWeakRef;

const retrieved = getCapturedScopesOnSpan(span);
expect(retrieved.scope).toBe(scope); // Regular scope should still be available
expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to GC
expect(retrieved.scope).toBe(scope);
expect(retrieved.isolationScope).toBeUndefined();
expect(mockIsolationScopeWeakRef.deref).toHaveBeenCalled();
});

it('handles corrupted WeakRef objects gracefully', () => {
it('handles corrupted WeakRef objects gracefully for isolation scope', () => {
const span = createMockSpan();
const scope = new Scope();

// Set up a regular scope (stored directly) and a corrupted isolation scope WeakRef
const spanWithScopes = span as any;
spanWithScopes._sentryScope = scope; // Regular scope stored directly
spanWithScopes._sentryScope = scope;
spanWithScopes._sentryIsolationScope = {
deref: vi.fn().mockImplementation(() => {
throw new Error('WeakRef deref failed');
}),
};

const retrieved = getCapturedScopesOnSpan(span);
expect(retrieved.scope).toBe(scope); // Regular scope should still be available
expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to error
expect(retrieved.scope).toBe(scope);
expect(retrieved.isolationScope).toBeUndefined();
});

it('preserves scope data when using WeakRef', () => {
Expand Down
31 changes: 9 additions & 22 deletions packages/opentelemetry/src/spanExporter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
getCapturedScopesOnSpan,
getDynamicSamplingContextFromSpan,
getStatusMessage,
LRUMap,
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
Expand All@@ -41,6 +42,7 @@ type SpanNodeCompleted = SpanNode & { span: ReadableSpan };

const MAX_SPAN_COUNT = 1000;
const DEFAULT_TIMEOUT = 300; // 5 min
const SENT_SPANS_MAX_SIZE = 10000;

interface FinishedSpanBucket {
timestampInS: number;
Expand DownExpand Up@@ -71,9 +73,7 @@ export class SentrySpanExporter {
private _finishedSpanBucketSize: number;
private _spansToBucketEntry: WeakMap<ReadableSpan, FinishedSpanBucket>;
private _lastCleanupTimestampInS: number;
// Essentially a a set of span ids that are already sent. The values are expiration
// times in this cache so we don't hold onto them indefinitely.
private _sentSpans: Map<string, number>;
private _sentSpans: LRUMap<string, number>;
/* Internally, we use a debounced flush to give some wiggle room to the span processor to accumulate more spans. */
private _debouncedFlush: ReturnType<typeof debounce>;

Expand All@@ -85,7 +85,7 @@ export class SentrySpanExporter {
this._finishedSpanBuckets = new Array(this._finishedSpanBucketSize).fill(undefined);
this._lastCleanupTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000);
this._spansToBucketEntry = new WeakMap();
this._sentSpans = new Map<string, number>();
this._sentSpans = new LRUMap<string, number>(SENT_SPANS_MAX_SIZE);
this._debouncedFlush = debounce(this.flush.bind(this), 1, { maxWait: 100 });
}

Expand DownExpand Up@@ -124,7 +124,7 @@ export class SentrySpanExporter {

// If the span doesn't have a local parent ID (it's a root span), we're gonna flush all the ended spans
const localParentId = getLocalParentId(span);
if (!localParentId || this._sentSpans.has(localParentId)) {
if (!localParentId || this._sentSpans.get(localParentId)) {
this._debouncedFlush();
}
}
Expand All@@ -137,7 +137,6 @@ export class SentrySpanExporter {
public flush(): void {
const finishedSpans = this._finishedSpanBuckets.flatMap(bucket => (bucket ? Array.from(bucket.spans) : []));

this._flushSentSpanCache();
const sentSpans = this._maybeSend(finishedSpans);

const sentSpanCount = sentSpans.size;
Expand All@@ -147,15 +146,14 @@ export class SentrySpanExporter {
`SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} spans are waiting for their parent spans to finish`,
);

const expirationDate = _INTERNAL_safeDateNow() + DEFAULT_TIMEOUT * 1000;

for (const span of sentSpans) {
this._sentSpans.set(span.spanContext().spanId, expirationDate);
this._sentSpans.set(span.spanContext().spanId, 1);
const bucketEntry = this._spansToBucketEntry.get(span);
if (bucketEntry) {
bucketEntry.spans.delete(span);
}
}

// Cancel a pending debounced flush, if there is one
// This can be relevant if we directly flush, circumventing the debounce
// in that case, we want to cancel any pending debounced flush
Expand DownExpand Up@@ -193,7 +191,7 @@ export class SentrySpanExporter {
const transactionEvent = createTransactionForOtelSpan(span);

// Add an attribute to the transaction event to indicate that this transaction is an orphaned transaction
if (root.parentNode && this._sentSpans.has(root.parentNode.id)) {
if (root.parentNode && this._sentSpans.get(root.parentNode.id)) {
const traceData = transactionEvent.contexts?.trace?.data;
if (traceData) {
traceData['sentry.parent_span_already_sent'] = true;
Expand DownExpand Up@@ -235,20 +233,9 @@ export class SentrySpanExporter {
return sentSpans;
}

/** Remove "expired" span id entries from the _sentSpans cache. */
private _flushSentSpanCache(): void {
const currentTimestamp = _INTERNAL_safeDateNow();
// Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297
for (const [spanId, expirationTime] of this._sentSpans.entries()) {
if (expirationTime <= currentTimestamp) {
this._sentSpans.delete(spanId);
}
}
}

/** Check if a node is a completed root node or a node whose parent has already been sent */
private _nodeIsCompletedRootNodeOrHasSentParent(node: SpanNode): node is SpanNodeCompleted {
return !!node.span && (!node.parentNode || this._sentSpans.has(node.parentNode.id));
return !!node.span && (!node.parentNode || !!this._sentSpans.get(node.parentNode.id));
}

/** Get all completed root nodes from a list of nodes */
Expand Down
25 changes: 7 additions & 18 deletions packages/opentelemetry/test/integration/transactions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -612,7 +612,7 @@ describe('Integration | Transactions', () => {
expect(finishedSpans.length).toBe(0);
});

it('discards child spans that are finished after 5 minutes their parent span has been sent', async () => {
it('sends child spans that are finished after 5 minutes as orphaned transactions', async () => {
const timeout = 5 * 60 * 1000;
const now = Date.now();
vi.useFakeTimers();
Expand All@@ -631,14 +631,6 @@ describe('Integration | Transactions', () => {
},
});

const spanProcessor = getSpanProcessor();

const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined;

if (!exporter) {
throw new Error('No exporter found, aborting test...');
}

startSpanManual({ name: 'test name' }, async span => {
const subSpan = startInactiveSpan({ name: 'inner span 1' });
subSpan.end();
Expand All@@ -654,18 +646,15 @@ describe('Integration | Transactions', () => {

vi.advanceTimersByTime(timeout + 2);

expect(transactions).toHaveLength(1);
expect(transactions).toHaveLength(2);
expect(transactions[0]?.spans).toHaveLength(1);

// subSpan2 is pending (and will eventually be cleaned up)
const finishedSpans: any = [];
exporter['_finishedSpanBuckets'].forEach(bucket => {
if (bucket) {
finishedSpans.push(...bucket.spans);
}
expect(transactions[1]?.transaction).toBe('inner span 2');
expect(transactions[1]?.contexts?.trace?.data).toEqual({
'sentry.parent_span_already_sent': true,
'sentry.origin': 'manual',
'sentry.source': 'custom',
});
expect(finishedSpans.length).toBe(1);
expect(finishedSpans[0]?.name).toBe('inner span 2');
});

it('only considers sent spans, not finished spans, for flushing orphaned spans of sent spans', async () => {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
8 changes: 5 additions & 3 deletions packages/core/src/utils/spanOnScope.ts
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
import type { Scope } from '../scope';
import type { Span } from '../types/span';
import { addNonEnumerableProperty } from '../utils/object';
import { derefWeakRef, makeWeakRef, type MaybeWeakRef } from './weakRef';

const SCOPE_SPAN_FIELD = '_sentrySpan';

type ScopeWithMaybeSpan = Scope & {
[SCOPE_SPAN_FIELD]?: Span;
[SCOPE_SPAN_FIELD]?: MaybeWeakRef<Span>;
};

/**
Expand All@@ -14,7 +15,8 @@ type ScopeWithMaybeSpan = Scope & {
*/
export function _setSpanForScope(scope: Scope, span: Span | undefined): void {
if (span) {
addNonEnumerableProperty(scope as ScopeWithMaybeSpan, SCOPE_SPAN_FIELD, span);
// Use WeakRef to avoid circular reference with span holding scope
addNonEnumerableProperty(scope as ScopeWithMaybeSpan, SCOPE_SPAN_FIELD, makeWeakRef(span));
} else {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete (scope as ScopeWithMaybeSpan)[SCOPE_SPAN_FIELD];
Expand All@@ -26,5 +28,5 @@ export function _setSpanForScope(scope: Scope, span: Span | undefined): void {
* NOTE: This should NOT be used directly, but is only used internally by the trace methods.
*/
export function _getSpanForScope(scope: ScopeWithMaybeSpan): Span | undefined {
return scope[SCOPE_SPAN_FIELD];
return derefWeakRef(scope[SCOPE_SPAN_FIELD]);
}
37 changes: 13 additions & 24 deletions packages/core/test/lib/tracing/utils.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,17 +44,16 @@ describe('tracing utils', () => {
expect(retrieved.isolationScope).toBeUndefined();
});

it('uses WeakRef only for isolation scopes', () => {
it('uses WeakRef only for isolation scope, stores scope directly', () => {
const span = createMockSpan();
const scope = new Scope();
const isolationScope = new Scope();

setCapturedScopesOnSpan(span, scope, isolationScope);

// Check that only isolation scope is wrapped with WeakRef
const spanWithScopes = span as any;
expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope stored directly
expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef); // Isolation scope wrapped
expect(spanWithScopes._sentryScope).toBe(scope);
expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef);

// Verify we can still retrieve the scopes
const retrieved = getCapturedScopesOnSpan(span);
Expand All@@ -77,14 +76,8 @@ describe('tracing utils', () => {

// Check that both scopes are stored directly when WeakRef is not available
const spanWithScopes = span as any;
expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope always stored directly
expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope); // Isolation scope falls back to direct storage

// When WeakRef is available, ensure regular scope is not wrapped but isolation scope would be
if (originalWeakRef) {
expect(spanWithScopes._sentryScope).not.toBeInstanceOf(originalWeakRef);
expect(spanWithScopes._sentryIsolationScope).not.toBeInstanceOf(originalWeakRef);
}
expect(spanWithScopes._sentryScope).toBe(scope);
expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope);

// Verify we can still retrieve the scopes
const retrieved = getCapturedScopesOnSpan(span);
Expand All@@ -96,46 +89,42 @@ describe('tracing utils', () => {
}
});

it('handles WeakRef deref returning undefined gracefully', () => {
it('handles WeakRef deref returning undefined gracefully for isolation scope', () => {
const span = createMockSpan();
const scope = new Scope();
const isolationScope = new Scope();

setCapturedScopesOnSpan(span, scope, isolationScope);

// Mock WeakRef.deref to return undefined for isolation scope (simulating garbage collection)
// Regular scope is stored directly, so it should always be available
// Mock isolation scope WeakRef to return undefined (simulating garbage collection)
const spanWithScopes = span as any;
const mockIsolationScopeWeakRef = {
deref: vi.fn().mockReturnValue(undefined),
};

// Keep the regular scope as is (stored directly)
// Only replace the isolation scope with a mock WeakRef
spanWithScopes._sentryIsolationScope = mockIsolationScopeWeakRef;

const retrieved = getCapturedScopesOnSpan(span);
expect(retrieved.scope).toBe(scope); // Regular scope should still be available
expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to GC
expect(retrieved.scope).toBe(scope);
expect(retrieved.isolationScope).toBeUndefined();
expect(mockIsolationScopeWeakRef.deref).toHaveBeenCalled();
});

it('handles corrupted WeakRef objects gracefully', () => {
it('handles corrupted WeakRef objects gracefully for isolation scope', () => {
const span = createMockSpan();
const scope = new Scope();

// Set up a regular scope (stored directly) and a corrupted isolation scope WeakRef
const spanWithScopes = span as any;
spanWithScopes._sentryScope = scope; // Regular scope stored directly
spanWithScopes._sentryScope = scope;
spanWithScopes._sentryIsolationScope = {
deref: vi.fn().mockImplementation(() => {
throw new Error('WeakRef deref failed');
}),
};

const retrieved = getCapturedScopesOnSpan(span);
expect(retrieved.scope).toBe(scope); // Regular scope should still be available
expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to error
expect(retrieved.scope).toBe(scope);
expect(retrieved.isolationScope).toBeUndefined();
});

it('preserves scope data when using WeakRef', () => {
Expand Down
31 changes: 9 additions & 22 deletions packages/opentelemetry/src/spanExporter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
getCapturedScopesOnSpan,
getDynamicSamplingContextFromSpan,
getStatusMessage,
LRUMap,
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
Expand All@@ -41,6 +42,7 @@ type SpanNodeCompleted = SpanNode & { span: ReadableSpan };

const MAX_SPAN_COUNT = 1000;
const DEFAULT_TIMEOUT = 300; // 5 min
const SENT_SPANS_MAX_SIZE = 10000;

interface FinishedSpanBucket {
timestampInS: number;
Expand DownExpand Up@@ -71,9 +73,7 @@ export class SentrySpanExporter {
private _finishedSpanBucketSize: number;
private _spansToBucketEntry: WeakMap<ReadableSpan, FinishedSpanBucket>;
private _lastCleanupTimestampInS: number;
// Essentially a a set of span ids that are already sent. The values are expiration
// times in this cache so we don't hold onto them indefinitely.
private _sentSpans: Map<string, number>;
private _sentSpans: LRUMap<string, number>;
/* Internally, we use a debounced flush to give some wiggle room to the span processor to accumulate more spans. */
private _debouncedFlush: ReturnType<typeof debounce>;

Expand All@@ -85,7 +85,7 @@ export class SentrySpanExporter {
this._finishedSpanBuckets = new Array(this._finishedSpanBucketSize).fill(undefined);
this._lastCleanupTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000);
this._spansToBucketEntry = new WeakMap();
this._sentSpans = new Map<string, number>();
this._sentSpans = new LRUMap<string, number>(SENT_SPANS_MAX_SIZE);
this._debouncedFlush = debounce(this.flush.bind(this), 1, { maxWait: 100 });
}

Expand DownExpand Up@@ -124,7 +124,7 @@ export class SentrySpanExporter {

// If the span doesn't have a local parent ID (it's a root span), we're gonna flush all the ended spans
const localParentId = getLocalParentId(span);
if (!localParentId || this._sentSpans.has(localParentId)) {
if (!localParentId || this._sentSpans.get(localParentId)) {
this._debouncedFlush();
}
}
Expand All@@ -137,7 +137,6 @@ export class SentrySpanExporter {
public flush(): void {
const finishedSpans = this._finishedSpanBuckets.flatMap(bucket => (bucket ? Array.from(bucket.spans) : []));

this._flushSentSpanCache();
const sentSpans = this._maybeSend(finishedSpans);

const sentSpanCount = sentSpans.size;
Expand All@@ -147,15 +146,14 @@ export class SentrySpanExporter {
`SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} spans are waiting for their parent spans to finish`,
);

const expirationDate = _INTERNAL_safeDateNow() + DEFAULT_TIMEOUT * 1000;

for (const span of sentSpans) {
this._sentSpans.set(span.spanContext().spanId, expirationDate);
this._sentSpans.set(span.spanContext().spanId, 1);
const bucketEntry = this._spansToBucketEntry.get(span);
if (bucketEntry) {
bucketEntry.spans.delete(span);
}
}

// Cancel a pending debounced flush, if there is one
// This can be relevant if we directly flush, circumventing the debounce
// in that case, we want to cancel any pending debounced flush
Expand DownExpand Up@@ -193,7 +191,7 @@ export class SentrySpanExporter {
const transactionEvent = createTransactionForOtelSpan(span);

// Add an attribute to the transaction event to indicate that this transaction is an orphaned transaction
if (root.parentNode && this._sentSpans.has(root.parentNode.id)) {
if (root.parentNode && this._sentSpans.get(root.parentNode.id)) {
const traceData = transactionEvent.contexts?.trace?.data;
if (traceData) {
traceData['sentry.parent_span_already_sent'] = true;
Expand DownExpand Up@@ -235,20 +233,9 @@ export class SentrySpanExporter {
return sentSpans;
}

/** Remove "expired" span id entries from the _sentSpans cache. */
private _flushSentSpanCache(): void {
const currentTimestamp = _INTERNAL_safeDateNow();
// Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297
for (const [spanId, expirationTime] of this._sentSpans.entries()) {
if (expirationTime <= currentTimestamp) {
this._sentSpans.delete(spanId);
}
}
}

/** Check if a node is a completed root node or a node whose parent has already been sent */
private _nodeIsCompletedRootNodeOrHasSentParent(node: SpanNode): node is SpanNodeCompleted {
return !!node.span && (!node.parentNode || this._sentSpans.has(node.parentNode.id));
return !!node.span && (!node.parentNode || !!this._sentSpans.get(node.parentNode.id));
}

/** Get all completed root nodes from a list of nodes */
Expand Down
25 changes: 7 additions & 18 deletions packages/opentelemetry/test/integration/transactions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -612,7 +612,7 @@ describe('Integration | Transactions', () => {
expect(finishedSpans.length).toBe(0);
});

it('discards child spans that are finished after 5 minutes their parent span has been sent', async () => {
it('sends child spans that are finished after 5 minutes as orphaned transactions', async () => {
const timeout = 5 * 60 * 1000;
const now = Date.now();
vi.useFakeTimers();
Expand All@@ -631,14 +631,6 @@ describe('Integration | Transactions', () => {
},
});

const spanProcessor = getSpanProcessor();

const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined;

if (!exporter) {
throw new Error('No exporter found, aborting test...');
}

startSpanManual({ name: 'test name' }, async span => {
const subSpan = startInactiveSpan({ name: 'inner span 1' });
subSpan.end();
Expand All@@ -654,18 +646,15 @@ describe('Integration | Transactions', () => {

vi.advanceTimersByTime(timeout + 2);

expect(transactions).toHaveLength(1);
expect(transactions).toHaveLength(2);
expect(transactions[0]?.spans).toHaveLength(1);

// subSpan2 is pending (and will eventually be cleaned up)
const finishedSpans: any = [];
exporter['_finishedSpanBuckets'].forEach(bucket => {
if (bucket) {
finishedSpans.push(...bucket.spans);
}
expect(transactions[1]?.transaction).toBe('inner span 2');
expect(transactions[1]?.contexts?.trace?.data).toEqual({
'sentry.parent_span_already_sent': true,
'sentry.origin': 'manual',
'sentry.source': 'custom',
});
expect(finishedSpans.length).toBe(1);
expect(finishedSpans[0]?.name).toBe('inner span 2');
});

it('only considers sent spans, not finished spans, for flushing orphaned spans of sent spans', async () => {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
8 changes: 5 additions & 3 deletions packages/core/src/utils/spanOnScope.ts
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
import type { Scope } from '../scope';
import type { Span } from '../types/span';
import { addNonEnumerableProperty } from '../utils/object';
import { derefWeakRef, makeWeakRef, type MaybeWeakRef } from './weakRef';

const SCOPE_SPAN_FIELD = '_sentrySpan';

type ScopeWithMaybeSpan = Scope & {
[SCOPE_SPAN_FIELD]?: Span;
[SCOPE_SPAN_FIELD]?: MaybeWeakRef<Span>;
};

/**
Expand All@@ -14,7 +15,8 @@ type ScopeWithMaybeSpan = Scope & {
*/
export function _setSpanForScope(scope: Scope, span: Span | undefined): void {
if (span) {
addNonEnumerableProperty(scope as ScopeWithMaybeSpan, SCOPE_SPAN_FIELD, span);
// Use WeakRef to avoid circular reference with span holding scope
addNonEnumerableProperty(scope as ScopeWithMaybeSpan, SCOPE_SPAN_FIELD, makeWeakRef(span));
} else {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete (scope as ScopeWithMaybeSpan)[SCOPE_SPAN_FIELD];
Expand All@@ -26,5 +28,5 @@ export function _setSpanForScope(scope: Scope, span: Span | undefined): void {
* NOTE: This should NOT be used directly, but is only used internally by the trace methods.
*/
export function _getSpanForScope(scope: ScopeWithMaybeSpan): Span | undefined {
return scope[SCOPE_SPAN_FIELD];
return derefWeakRef(scope[SCOPE_SPAN_FIELD]);
}
37 changes: 13 additions & 24 deletions packages/core/test/lib/tracing/utils.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,17 +44,16 @@ describe('tracing utils', () => {
expect(retrieved.isolationScope).toBeUndefined();
});

it('uses WeakRef only for isolation scopes', () => {
it('uses WeakRef only for isolation scope, stores scope directly', () => {
const span = createMockSpan();
const scope = new Scope();
const isolationScope = new Scope();

setCapturedScopesOnSpan(span, scope, isolationScope);

// Check that only isolation scope is wrapped with WeakRef
const spanWithScopes = span as any;
expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope stored directly
expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef); // Isolation scope wrapped
expect(spanWithScopes._sentryScope).toBe(scope);
expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef);

// Verify we can still retrieve the scopes
const retrieved = getCapturedScopesOnSpan(span);
Expand All@@ -77,14 +76,8 @@ describe('tracing utils', () => {

// Check that both scopes are stored directly when WeakRef is not available
const spanWithScopes = span as any;
expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope always stored directly
expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope); // Isolation scope falls back to direct storage

// When WeakRef is available, ensure regular scope is not wrapped but isolation scope would be
if (originalWeakRef) {
expect(spanWithScopes._sentryScope).not.toBeInstanceOf(originalWeakRef);
expect(spanWithScopes._sentryIsolationScope).not.toBeInstanceOf(originalWeakRef);
}
expect(spanWithScopes._sentryScope).toBe(scope);
expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope);

// Verify we can still retrieve the scopes
const retrieved = getCapturedScopesOnSpan(span);
Expand All@@ -96,46 +89,42 @@ describe('tracing utils', () => {
}
});

it('handles WeakRef deref returning undefined gracefully', () => {
it('handles WeakRef deref returning undefined gracefully for isolation scope', () => {
const span = createMockSpan();
const scope = new Scope();
const isolationScope = new Scope();

setCapturedScopesOnSpan(span, scope, isolationScope);

// Mock WeakRef.deref to return undefined for isolation scope (simulating garbage collection)
// Regular scope is stored directly, so it should always be available
// Mock isolation scope WeakRef to return undefined (simulating garbage collection)
const spanWithScopes = span as any;
const mockIsolationScopeWeakRef = {
deref: vi.fn().mockReturnValue(undefined),
};

// Keep the regular scope as is (stored directly)
// Only replace the isolation scope with a mock WeakRef
spanWithScopes._sentryIsolationScope = mockIsolationScopeWeakRef;

const retrieved = getCapturedScopesOnSpan(span);
expect(retrieved.scope).toBe(scope); // Regular scope should still be available
expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to GC
expect(retrieved.scope).toBe(scope);
expect(retrieved.isolationScope).toBeUndefined();
expect(mockIsolationScopeWeakRef.deref).toHaveBeenCalled();
});

it('handles corrupted WeakRef objects gracefully', () => {
it('handles corrupted WeakRef objects gracefully for isolation scope', () => {
const span = createMockSpan();
const scope = new Scope();

// Set up a regular scope (stored directly) and a corrupted isolation scope WeakRef
const spanWithScopes = span as any;
spanWithScopes._sentryScope = scope; // Regular scope stored directly
spanWithScopes._sentryScope = scope;
spanWithScopes._sentryIsolationScope = {
deref: vi.fn().mockImplementation(() => {
throw new Error('WeakRef deref failed');
}),
};

const retrieved = getCapturedScopesOnSpan(span);
expect(retrieved.scope).toBe(scope); // Regular scope should still be available
expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to error
expect(retrieved.scope).toBe(scope);
expect(retrieved.isolationScope).toBeUndefined();
});

it('preserves scope data when using WeakRef', () => {
Expand Down
31 changes: 9 additions & 22 deletions packages/opentelemetry/src/spanExporter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
getCapturedScopesOnSpan,
getDynamicSamplingContextFromSpan,
getStatusMessage,
LRUMap,
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
Expand All@@ -41,6 +42,7 @@ type SpanNodeCompleted = SpanNode & { span: ReadableSpan };

const MAX_SPAN_COUNT = 1000;
const DEFAULT_TIMEOUT = 300; // 5 min
const SENT_SPANS_MAX_SIZE = 10000;

interface FinishedSpanBucket {
timestampInS: number;
Expand DownExpand Up@@ -71,9 +73,7 @@ export class SentrySpanExporter {
private _finishedSpanBucketSize: number;
private _spansToBucketEntry: WeakMap<ReadableSpan, FinishedSpanBucket>;
private _lastCleanupTimestampInS: number;
// Essentially a a set of span ids that are already sent. The values are expiration
// times in this cache so we don't hold onto them indefinitely.
private _sentSpans: Map<string, number>;
private _sentSpans: LRUMap<string, number>;
/* Internally, we use a debounced flush to give some wiggle room to the span processor to accumulate more spans. */
private _debouncedFlush: ReturnType<typeof debounce>;

Expand All@@ -85,7 +85,7 @@ export class SentrySpanExporter {
this._finishedSpanBuckets = new Array(this._finishedSpanBucketSize).fill(undefined);
this._lastCleanupTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000);
this._spansToBucketEntry = new WeakMap();
this._sentSpans = new Map<string, number>();
this._sentSpans = new LRUMap<string, number>(SENT_SPANS_MAX_SIZE);
this._debouncedFlush = debounce(this.flush.bind(this), 1, { maxWait: 100 });
}

Expand DownExpand Up@@ -124,7 +124,7 @@ export class SentrySpanExporter {

// If the span doesn't have a local parent ID (it's a root span), we're gonna flush all the ended spans
const localParentId = getLocalParentId(span);
if (!localParentId || this._sentSpans.has(localParentId)) {
if (!localParentId || this._sentSpans.get(localParentId)) {
this._debouncedFlush();
}
}
Expand All@@ -137,7 +137,6 @@ export class SentrySpanExporter {
public flush(): void {
const finishedSpans = this._finishedSpanBuckets.flatMap(bucket => (bucket ? Array.from(bucket.spans) : []));

this._flushSentSpanCache();
const sentSpans = this._maybeSend(finishedSpans);

const sentSpanCount = sentSpans.size;
Expand All@@ -147,15 +146,14 @@ export class SentrySpanExporter {
`SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} spans are waiting for their parent spans to finish`,
);

const expirationDate = _INTERNAL_safeDateNow() + DEFAULT_TIMEOUT * 1000;

for (const span of sentSpans) {
this._sentSpans.set(span.spanContext().spanId, expirationDate);
this._sentSpans.set(span.spanContext().spanId, 1);
const bucketEntry = this._spansToBucketEntry.get(span);
if (bucketEntry) {
bucketEntry.spans.delete(span);
}
}

// Cancel a pending debounced flush, if there is one
// This can be relevant if we directly flush, circumventing the debounce
// in that case, we want to cancel any pending debounced flush
Expand DownExpand Up@@ -193,7 +191,7 @@ export class SentrySpanExporter {
const transactionEvent = createTransactionForOtelSpan(span);

// Add an attribute to the transaction event to indicate that this transaction is an orphaned transaction
if (root.parentNode && this._sentSpans.has(root.parentNode.id)) {
if (root.parentNode && this._sentSpans.get(root.parentNode.id)) {
const traceData = transactionEvent.contexts?.trace?.data;
if (traceData) {
traceData['sentry.parent_span_already_sent'] = true;
Expand DownExpand Up@@ -235,20 +233,9 @@ export class SentrySpanExporter {
return sentSpans;
}

/** Remove "expired" span id entries from the _sentSpans cache. */
private _flushSentSpanCache(): void {
const currentTimestamp = _INTERNAL_safeDateNow();
// Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297
for (const [spanId, expirationTime] of this._sentSpans.entries()) {
if (expirationTime <= currentTimestamp) {
this._sentSpans.delete(spanId);
}
}
}

/** Check if a node is a completed root node or a node whose parent has already been sent */
private _nodeIsCompletedRootNodeOrHasSentParent(node: SpanNode): node is SpanNodeCompleted {
return !!node.span && (!node.parentNode || this._sentSpans.has(node.parentNode.id));
return !!node.span && (!node.parentNode || !!this._sentSpans.get(node.parentNode.id));
}

/** Get all completed root nodes from a list of nodes */
Expand Down
25 changes: 7 additions & 18 deletions packages/opentelemetry/test/integration/transactions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -612,7 +612,7 @@ describe('Integration | Transactions', () => {
expect(finishedSpans.length).toBe(0);
});

it('discards child spans that are finished after 5 minutes their parent span has been sent', async () => {
it('sends child spans that are finished after 5 minutes as orphaned transactions', async () => {
const timeout = 5 * 60 * 1000;
const now = Date.now();
vi.useFakeTimers();
Expand All@@ -631,14 +631,6 @@ describe('Integration | Transactions', () => {
},
});

const spanProcessor = getSpanProcessor();

const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined;

if (!exporter) {
throw new Error('No exporter found, aborting test...');
}

startSpanManual({ name: 'test name' }, async span => {
const subSpan = startInactiveSpan({ name: 'inner span 1' });
subSpan.end();
Expand All@@ -654,18 +646,15 @@ describe('Integration | Transactions', () => {

vi.advanceTimersByTime(timeout + 2);

expect(transactions).toHaveLength(1);
expect(transactions).toHaveLength(2);
expect(transactions[0]?.spans).toHaveLength(1);

// subSpan2 is pending (and will eventually be cleaned up)
const finishedSpans: any = [];
exporter['_finishedSpanBuckets'].forEach(bucket => {
if (bucket) {
finishedSpans.push(...bucket.spans);
}
expect(transactions[1]?.transaction).toBe('inner span 2');
expect(transactions[1]?.contexts?.trace?.data).toEqual({
'sentry.parent_span_already_sent': true,
'sentry.origin': 'manual',
'sentry.source': 'custom',
});
expect(finishedSpans.length).toBe(1);
expect(finishedSpans[0]?.name).toBe('inner span 2');
});

it('only considers sent spans, not finished spans, for flushing orphaned spans of sent spans', async () => {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
8 changes: 5 additions & 3 deletions packages/core/src/utils/spanOnScope.ts
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
import type { Scope } from '../scope';
import type { Span } from '../types/span';
import { addNonEnumerableProperty } from '../utils/object';
import { derefWeakRef, makeWeakRef, type MaybeWeakRef } from './weakRef';

const SCOPE_SPAN_FIELD = '_sentrySpan';

type ScopeWithMaybeSpan = Scope & {
[SCOPE_SPAN_FIELD]?: Span;
[SCOPE_SPAN_FIELD]?: MaybeWeakRef<Span>;
};

/**
Expand All@@ -14,7 +15,8 @@ type ScopeWithMaybeSpan = Scope & {
*/
export function _setSpanForScope(scope: Scope, span: Span | undefined): void {
if (span) {
addNonEnumerableProperty(scope as ScopeWithMaybeSpan, SCOPE_SPAN_FIELD, span);
// Use WeakRef to avoid circular reference with span holding scope
addNonEnumerableProperty(scope as ScopeWithMaybeSpan, SCOPE_SPAN_FIELD, makeWeakRef(span));
} else {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete (scope as ScopeWithMaybeSpan)[SCOPE_SPAN_FIELD];
Expand All@@ -26,5 +28,5 @@ export function _setSpanForScope(scope: Scope, span: Span | undefined): void {
* NOTE: This should NOT be used directly, but is only used internally by the trace methods.
*/
export function _getSpanForScope(scope: ScopeWithMaybeSpan): Span | undefined {
return scope[SCOPE_SPAN_FIELD];
return derefWeakRef(scope[SCOPE_SPAN_FIELD]);
}
37 changes: 13 additions & 24 deletions packages/core/test/lib/tracing/utils.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,17 +44,16 @@ describe('tracing utils', () => {
expect(retrieved.isolationScope).toBeUndefined();
});

it('uses WeakRef only for isolation scopes', () => {
it('uses WeakRef only for isolation scope, stores scope directly', () => {
const span = createMockSpan();
const scope = new Scope();
const isolationScope = new Scope();

setCapturedScopesOnSpan(span, scope, isolationScope);

// Check that only isolation scope is wrapped with WeakRef
const spanWithScopes = span as any;
expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope stored directly
expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef); // Isolation scope wrapped
expect(spanWithScopes._sentryScope).toBe(scope);
expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef);

// Verify we can still retrieve the scopes
const retrieved = getCapturedScopesOnSpan(span);
Expand All@@ -77,14 +76,8 @@ describe('tracing utils', () => {

// Check that both scopes are stored directly when WeakRef is not available
const spanWithScopes = span as any;
expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope always stored directly
expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope); // Isolation scope falls back to direct storage

// When WeakRef is available, ensure regular scope is not wrapped but isolation scope would be
if (originalWeakRef) {
expect(spanWithScopes._sentryScope).not.toBeInstanceOf(originalWeakRef);
expect(spanWithScopes._sentryIsolationScope).not.toBeInstanceOf(originalWeakRef);
}
expect(spanWithScopes._sentryScope).toBe(scope);
expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope);

// Verify we can still retrieve the scopes
const retrieved = getCapturedScopesOnSpan(span);
Expand All@@ -96,46 +89,42 @@ describe('tracing utils', () => {
}
});

it('handles WeakRef deref returning undefined gracefully', () => {
it('handles WeakRef deref returning undefined gracefully for isolation scope', () => {
const span = createMockSpan();
const scope = new Scope();
const isolationScope = new Scope();

setCapturedScopesOnSpan(span, scope, isolationScope);

// Mock WeakRef.deref to return undefined for isolation scope (simulating garbage collection)
// Regular scope is stored directly, so it should always be available
// Mock isolation scope WeakRef to return undefined (simulating garbage collection)
const spanWithScopes = span as any;
const mockIsolationScopeWeakRef = {
deref: vi.fn().mockReturnValue(undefined),
};

// Keep the regular scope as is (stored directly)
// Only replace the isolation scope with a mock WeakRef
spanWithScopes._sentryIsolationScope = mockIsolationScopeWeakRef;

const retrieved = getCapturedScopesOnSpan(span);
expect(retrieved.scope).toBe(scope); // Regular scope should still be available
expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to GC
expect(retrieved.scope).toBe(scope);
expect(retrieved.isolationScope).toBeUndefined();
expect(mockIsolationScopeWeakRef.deref).toHaveBeenCalled();
});

it('handles corrupted WeakRef objects gracefully', () => {
it('handles corrupted WeakRef objects gracefully for isolation scope', () => {
const span = createMockSpan();
const scope = new Scope();

// Set up a regular scope (stored directly) and a corrupted isolation scope WeakRef
const spanWithScopes = span as any;
spanWithScopes._sentryScope = scope; // Regular scope stored directly
spanWithScopes._sentryScope = scope;
spanWithScopes._sentryIsolationScope = {
deref: vi.fn().mockImplementation(() => {
throw new Error('WeakRef deref failed');
}),
};

const retrieved = getCapturedScopesOnSpan(span);
expect(retrieved.scope).toBe(scope); // Regular scope should still be available
expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to error
expect(retrieved.scope).toBe(scope);
expect(retrieved.isolationScope).toBeUndefined();
});

it('preserves scope data when using WeakRef', () => {
Expand Down
31 changes: 9 additions & 22 deletions packages/opentelemetry/src/spanExporter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
getCapturedScopesOnSpan,
getDynamicSamplingContextFromSpan,
getStatusMessage,
LRUMap,
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
Expand All@@ -41,6 +42,7 @@ type SpanNodeCompleted = SpanNode & { span: ReadableSpan };

const MAX_SPAN_COUNT = 1000;
const DEFAULT_TIMEOUT = 300; // 5 min
const SENT_SPANS_MAX_SIZE = 10000;

interface FinishedSpanBucket {
timestampInS: number;
Expand DownExpand Up@@ -71,9 +73,7 @@ export class SentrySpanExporter {
private _finishedSpanBucketSize: number;
private _spansToBucketEntry: WeakMap<ReadableSpan, FinishedSpanBucket>;
private _lastCleanupTimestampInS: number;
// Essentially a a set of span ids that are already sent. The values are expiration
// times in this cache so we don't hold onto them indefinitely.
private _sentSpans: Map<string, number>;
private _sentSpans: LRUMap<string, number>;
/* Internally, we use a debounced flush to give some wiggle room to the span processor to accumulate more spans. */
private _debouncedFlush: ReturnType<typeof debounce>;

Expand All@@ -85,7 +85,7 @@ export class SentrySpanExporter {
this._finishedSpanBuckets = new Array(this._finishedSpanBucketSize).fill(undefined);
this._lastCleanupTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000);
this._spansToBucketEntry = new WeakMap();
this._sentSpans = new Map<string, number>();
this._sentSpans = new LRUMap<string, number>(SENT_SPANS_MAX_SIZE);
this._debouncedFlush = debounce(this.flush.bind(this), 1, { maxWait: 100 });
}

Expand DownExpand Up@@ -124,7 +124,7 @@ export class SentrySpanExporter {

// If the span doesn't have a local parent ID (it's a root span), we're gonna flush all the ended spans
const localParentId = getLocalParentId(span);
if (!localParentId || this._sentSpans.has(localParentId)) {
if (!localParentId || this._sentSpans.get(localParentId)) {
this._debouncedFlush();
}
}
Expand All@@ -137,7 +137,6 @@ export class SentrySpanExporter {
public flush(): void {
const finishedSpans = this._finishedSpanBuckets.flatMap(bucket => (bucket ? Array.from(bucket.spans) : []));

this._flushSentSpanCache();
const sentSpans = this._maybeSend(finishedSpans);

const sentSpanCount = sentSpans.size;
Expand All@@ -147,15 +146,14 @@ export class SentrySpanExporter {
`SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} spans are waiting for their parent spans to finish`,
);

const expirationDate = _INTERNAL_safeDateNow() + DEFAULT_TIMEOUT * 1000;

for (const span of sentSpans) {
this._sentSpans.set(span.spanContext().spanId, expirationDate);
this._sentSpans.set(span.spanContext().spanId, 1);
const bucketEntry = this._spansToBucketEntry.get(span);
if (bucketEntry) {
bucketEntry.spans.delete(span);
}
}

// Cancel a pending debounced flush, if there is one
// This can be relevant if we directly flush, circumventing the debounce
// in that case, we want to cancel any pending debounced flush
Expand DownExpand Up@@ -193,7 +191,7 @@ export class SentrySpanExporter {
const transactionEvent = createTransactionForOtelSpan(span);

// Add an attribute to the transaction event to indicate that this transaction is an orphaned transaction
if (root.parentNode && this._sentSpans.has(root.parentNode.id)) {
if (root.parentNode && this._sentSpans.get(root.parentNode.id)) {
const traceData = transactionEvent.contexts?.trace?.data;
if (traceData) {
traceData['sentry.parent_span_already_sent'] = true;
Expand DownExpand Up@@ -235,20 +233,9 @@ export class SentrySpanExporter {
return sentSpans;
}

/** Remove "expired" span id entries from the _sentSpans cache. */
private _flushSentSpanCache(): void {
const currentTimestamp = _INTERNAL_safeDateNow();
// Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297
for (const [spanId, expirationTime] of this._sentSpans.entries()) {
if (expirationTime <= currentTimestamp) {
this._sentSpans.delete(spanId);
}
}
}

/** Check if a node is a completed root node or a node whose parent has already been sent */
private _nodeIsCompletedRootNodeOrHasSentParent(node: SpanNode): node is SpanNodeCompleted {
return !!node.span && (!node.parentNode || this._sentSpans.has(node.parentNode.id));
return !!node.span && (!node.parentNode || !!this._sentSpans.get(node.parentNode.id));
}

/** Get all completed root nodes from a list of nodes */
Expand Down
25 changes: 7 additions & 18 deletions packages/opentelemetry/test/integration/transactions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -612,7 +612,7 @@ describe('Integration | Transactions', () => {
expect(finishedSpans.length).toBe(0);
});

it('discards child spans that are finished after 5 minutes their parent span has been sent', async () => {
it('sends child spans that are finished after 5 minutes as orphaned transactions', async () => {
const timeout = 5 * 60 * 1000;
const now = Date.now();
vi.useFakeTimers();
Expand All@@ -631,14 +631,6 @@ describe('Integration | Transactions', () => {
},
});

const spanProcessor = getSpanProcessor();

const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined;

if (!exporter) {
throw new Error('No exporter found, aborting test...');
}

startSpanManual({ name: 'test name' }, async span => {
const subSpan = startInactiveSpan({ name: 'inner span 1' });
subSpan.end();
Expand All@@ -654,18 +646,15 @@ describe('Integration | Transactions', () => {

vi.advanceTimersByTime(timeout + 2);

expect(transactions).toHaveLength(1);
expect(transactions).toHaveLength(2);
expect(transactions[0]?.spans).toHaveLength(1);

// subSpan2 is pending (and will eventually be cleaned up)
const finishedSpans: any = [];
exporter['_finishedSpanBuckets'].forEach(bucket => {
if (bucket) {
finishedSpans.push(...bucket.spans);
}
expect(transactions[1]?.transaction).toBe('inner span 2');
expect(transactions[1]?.contexts?.trace?.data).toEqual({
'sentry.parent_span_already_sent': true,
'sentry.origin': 'manual',
'sentry.source': 'custom',
});
expect(finishedSpans.length).toBe(1);
expect(finishedSpans[0]?.name).toBe('inner span 2');
});

it('only considers sent spans, not finished spans, for flushing orphaned spans of sent spans', async () => {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
8 changes: 5 additions & 3 deletions packages/core/src/utils/spanOnScope.ts
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
import type { Scope } from '../scope';
import type { Span } from '../types/span';
import { addNonEnumerableProperty } from '../utils/object';
import { derefWeakRef, makeWeakRef, type MaybeWeakRef } from './weakRef';

const SCOPE_SPAN_FIELD = '_sentrySpan';

type ScopeWithMaybeSpan = Scope & {
[SCOPE_SPAN_FIELD]?: Span;
[SCOPE_SPAN_FIELD]?: MaybeWeakRef<Span>;
};

/**
Expand All@@ -14,7 +15,8 @@ type ScopeWithMaybeSpan = Scope & {
*/
export function _setSpanForScope(scope: Scope, span: Span | undefined): void {
if (span) {
addNonEnumerableProperty(scope as ScopeWithMaybeSpan, SCOPE_SPAN_FIELD, span);
// Use WeakRef to avoid circular reference with span holding scope
addNonEnumerableProperty(scope as ScopeWithMaybeSpan, SCOPE_SPAN_FIELD, makeWeakRef(span));
} else {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete (scope as ScopeWithMaybeSpan)[SCOPE_SPAN_FIELD];
Expand All@@ -26,5 +28,5 @@ export function _setSpanForScope(scope: Scope, span: Span | undefined): void {
* NOTE: This should NOT be used directly, but is only used internally by the trace methods.
*/
export function _getSpanForScope(scope: ScopeWithMaybeSpan): Span | undefined {
return scope[SCOPE_SPAN_FIELD];
return derefWeakRef(scope[SCOPE_SPAN_FIELD]);
}
37 changes: 13 additions & 24 deletions packages/core/test/lib/tracing/utils.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,17 +44,16 @@ describe('tracing utils', () => {
expect(retrieved.isolationScope).toBeUndefined();
});

it('uses WeakRef only for isolation scopes', () => {
it('uses WeakRef only for isolation scope, stores scope directly', () => {
const span = createMockSpan();
const scope = new Scope();
const isolationScope = new Scope();

setCapturedScopesOnSpan(span, scope, isolationScope);

// Check that only isolation scope is wrapped with WeakRef
const spanWithScopes = span as any;
expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope stored directly
expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef); // Isolation scope wrapped
expect(spanWithScopes._sentryScope).toBe(scope);
expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef);

// Verify we can still retrieve the scopes
const retrieved = getCapturedScopesOnSpan(span);
Expand All@@ -77,14 +76,8 @@ describe('tracing utils', () => {

// Check that both scopes are stored directly when WeakRef is not available
const spanWithScopes = span as any;
expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope always stored directly
expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope); // Isolation scope falls back to direct storage

// When WeakRef is available, ensure regular scope is not wrapped but isolation scope would be
if (originalWeakRef) {
expect(spanWithScopes._sentryScope).not.toBeInstanceOf(originalWeakRef);
expect(spanWithScopes._sentryIsolationScope).not.toBeInstanceOf(originalWeakRef);
}
expect(spanWithScopes._sentryScope).toBe(scope);
expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope);

// Verify we can still retrieve the scopes
const retrieved = getCapturedScopesOnSpan(span);
Expand All@@ -96,46 +89,42 @@ describe('tracing utils', () => {
}
});

it('handles WeakRef deref returning undefined gracefully', () => {
it('handles WeakRef deref returning undefined gracefully for isolation scope', () => {
const span = createMockSpan();
const scope = new Scope();
const isolationScope = new Scope();

setCapturedScopesOnSpan(span, scope, isolationScope);

// Mock WeakRef.deref to return undefined for isolation scope (simulating garbage collection)
// Regular scope is stored directly, so it should always be available
// Mock isolation scope WeakRef to return undefined (simulating garbage collection)
const spanWithScopes = span as any;
const mockIsolationScopeWeakRef = {
deref: vi.fn().mockReturnValue(undefined),
};

// Keep the regular scope as is (stored directly)
// Only replace the isolation scope with a mock WeakRef
spanWithScopes._sentryIsolationScope = mockIsolationScopeWeakRef;

const retrieved = getCapturedScopesOnSpan(span);
expect(retrieved.scope).toBe(scope); // Regular scope should still be available
expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to GC
expect(retrieved.scope).toBe(scope);
expect(retrieved.isolationScope).toBeUndefined();
expect(mockIsolationScopeWeakRef.deref).toHaveBeenCalled();
});

it('handles corrupted WeakRef objects gracefully', () => {
it('handles corrupted WeakRef objects gracefully for isolation scope', () => {
const span = createMockSpan();
const scope = new Scope();

// Set up a regular scope (stored directly) and a corrupted isolation scope WeakRef
const spanWithScopes = span as any;
spanWithScopes._sentryScope = scope; // Regular scope stored directly
spanWithScopes._sentryScope = scope;
spanWithScopes._sentryIsolationScope = {
deref: vi.fn().mockImplementation(() => {
throw new Error('WeakRef deref failed');
}),
};

const retrieved = getCapturedScopesOnSpan(span);
expect(retrieved.scope).toBe(scope); // Regular scope should still be available
expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to error
expect(retrieved.scope).toBe(scope);
expect(retrieved.isolationScope).toBeUndefined();
});

it('preserves scope data when using WeakRef', () => {
Expand Down
31 changes: 9 additions & 22 deletions packages/opentelemetry/src/spanExporter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
getCapturedScopesOnSpan,
getDynamicSamplingContextFromSpan,
getStatusMessage,
LRUMap,
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
Expand All@@ -41,6 +42,7 @@ type SpanNodeCompleted = SpanNode & { span: ReadableSpan };

const MAX_SPAN_COUNT = 1000;
const DEFAULT_TIMEOUT = 300; // 5 min
const SENT_SPANS_MAX_SIZE = 10000;

interface FinishedSpanBucket {
timestampInS: number;
Expand DownExpand Up@@ -71,9 +73,7 @@ export class SentrySpanExporter {
private _finishedSpanBucketSize: number;
private _spansToBucketEntry: WeakMap<ReadableSpan, FinishedSpanBucket>;
private _lastCleanupTimestampInS: number;
// Essentially a a set of span ids that are already sent. The values are expiration
// times in this cache so we don't hold onto them indefinitely.
private _sentSpans: Map<string, number>;
private _sentSpans: LRUMap<string, number>;
/* Internally, we use a debounced flush to give some wiggle room to the span processor to accumulate more spans. */
private _debouncedFlush: ReturnType<typeof debounce>;

Expand All@@ -85,7 +85,7 @@ export class SentrySpanExporter {
this._finishedSpanBuckets = new Array(this._finishedSpanBucketSize).fill(undefined);
this._lastCleanupTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000);
this._spansToBucketEntry = new WeakMap();
this._sentSpans = new Map<string, number>();
this._sentSpans = new LRUMap<string, number>(SENT_SPANS_MAX_SIZE);
this._debouncedFlush = debounce(this.flush.bind(this), 1, { maxWait: 100 });
}

Expand DownExpand Up@@ -124,7 +124,7 @@ export class SentrySpanExporter {

// If the span doesn't have a local parent ID (it's a root span), we're gonna flush all the ended spans
const localParentId = getLocalParentId(span);
if (!localParentId || this._sentSpans.has(localParentId)) {
if (!localParentId || this._sentSpans.get(localParentId)) {
this._debouncedFlush();
}
}
Expand All@@ -137,7 +137,6 @@ export class SentrySpanExporter {
public flush(): void {
const finishedSpans = this._finishedSpanBuckets.flatMap(bucket => (bucket ? Array.from(bucket.spans) : []));

this._flushSentSpanCache();
const sentSpans = this._maybeSend(finishedSpans);

const sentSpanCount = sentSpans.size;
Expand All@@ -147,15 +146,14 @@ export class SentrySpanExporter {
`SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} spans are waiting for their parent spans to finish`,
);

const expirationDate = _INTERNAL_safeDateNow() + DEFAULT_TIMEOUT * 1000;

for (const span of sentSpans) {
this._sentSpans.set(span.spanContext().spanId, expirationDate);
this._sentSpans.set(span.spanContext().spanId, 1);
const bucketEntry = this._spansToBucketEntry.get(span);
if (bucketEntry) {
bucketEntry.spans.delete(span);
}
}

// Cancel a pending debounced flush, if there is one
// This can be relevant if we directly flush, circumventing the debounce
// in that case, we want to cancel any pending debounced flush
Expand DownExpand Up@@ -193,7 +191,7 @@ export class SentrySpanExporter {
const transactionEvent = createTransactionForOtelSpan(span);

// Add an attribute to the transaction event to indicate that this transaction is an orphaned transaction
if (root.parentNode && this._sentSpans.has(root.parentNode.id)) {
if (root.parentNode && this._sentSpans.get(root.parentNode.id)) {
const traceData = transactionEvent.contexts?.trace?.data;
if (traceData) {
traceData['sentry.parent_span_already_sent'] = true;
Expand DownExpand Up@@ -235,20 +233,9 @@ export class SentrySpanExporter {
return sentSpans;
}

/** Remove "expired" span id entries from the _sentSpans cache. */
private _flushSentSpanCache(): void {
const currentTimestamp = _INTERNAL_safeDateNow();
// Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297
for (const [spanId, expirationTime] of this._sentSpans.entries()) {
if (expirationTime <= currentTimestamp) {
this._sentSpans.delete(spanId);
}
}
}

/** Check if a node is a completed root node or a node whose parent has already been sent */
private _nodeIsCompletedRootNodeOrHasSentParent(node: SpanNode): node is SpanNodeCompleted {
return !!node.span && (!node.parentNode || this._sentSpans.has(node.parentNode.id));
return !!node.span && (!node.parentNode || !!this._sentSpans.get(node.parentNode.id));
}

/** Get all completed root nodes from a list of nodes */
Expand Down
25 changes: 7 additions & 18 deletions packages/opentelemetry/test/integration/transactions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -612,7 +612,7 @@ describe('Integration | Transactions', () => {
expect(finishedSpans.length).toBe(0);
});

it('discards child spans that are finished after 5 minutes their parent span has been sent', async () => {
it('sends child spans that are finished after 5 minutes as orphaned transactions', async () => {
const timeout = 5 * 60 * 1000;
const now = Date.now();
vi.useFakeTimers();
Expand All@@ -631,14 +631,6 @@ describe('Integration | Transactions', () => {
},
});

const spanProcessor = getSpanProcessor();

const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined;

if (!exporter) {
throw new Error('No exporter found, aborting test...');
}

startSpanManual({ name: 'test name' }, async span => {
const subSpan = startInactiveSpan({ name: 'inner span 1' });
subSpan.end();
Expand All@@ -654,18 +646,15 @@ describe('Integration | Transactions', () => {

vi.advanceTimersByTime(timeout + 2);

expect(transactions).toHaveLength(1);
expect(transactions).toHaveLength(2);
expect(transactions[0]?.spans).toHaveLength(1);

// subSpan2 is pending (and will eventually be cleaned up)
const finishedSpans: any = [];
exporter['_finishedSpanBuckets'].forEach(bucket => {
if (bucket) {
finishedSpans.push(...bucket.spans);
}
expect(transactions[1]?.transaction).toBe('inner span 2');
expect(transactions[1]?.contexts?.trace?.data).toEqual({
'sentry.parent_span_already_sent': true,
'sentry.origin': 'manual',
'sentry.source': 'custom',
});
expect(finishedSpans.length).toBe(1);
expect(finishedSpans[0]?.name).toBe('inner span 2');
});

it('only considers sent spans, not finished spans, for flushing orphaned spans of sent spans', async () => {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
8 changes: 5 additions & 3 deletions packages/core/src/utils/spanOnScope.ts
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
import type { Scope } from '../scope';
import type { Span } from '../types/span';
import { addNonEnumerableProperty } from '../utils/object';
import { derefWeakRef, makeWeakRef, type MaybeWeakRef } from './weakRef';

const SCOPE_SPAN_FIELD = '_sentrySpan';

type ScopeWithMaybeSpan = Scope & {
[SCOPE_SPAN_FIELD]?: Span;
[SCOPE_SPAN_FIELD]?: MaybeWeakRef<Span>;
};

/**
Expand All@@ -14,7 +15,8 @@ type ScopeWithMaybeSpan = Scope & {
*/
export function _setSpanForScope(scope: Scope, span: Span | undefined): void {
if (span) {
addNonEnumerableProperty(scope as ScopeWithMaybeSpan, SCOPE_SPAN_FIELD, span);
// Use WeakRef to avoid circular reference with span holding scope
addNonEnumerableProperty(scope as ScopeWithMaybeSpan, SCOPE_SPAN_FIELD, makeWeakRef(span));
} else {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete (scope as ScopeWithMaybeSpan)[SCOPE_SPAN_FIELD];
Expand All@@ -26,5 +28,5 @@ export function _setSpanForScope(scope: Scope, span: Span | undefined): void {
* NOTE: This should NOT be used directly, but is only used internally by the trace methods.
*/
export function _getSpanForScope(scope: ScopeWithMaybeSpan): Span | undefined {
return scope[SCOPE_SPAN_FIELD];
return derefWeakRef(scope[SCOPE_SPAN_FIELD]);
}
37 changes: 13 additions & 24 deletions packages/core/test/lib/tracing/utils.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,17 +44,16 @@ describe('tracing utils', () => {
expect(retrieved.isolationScope).toBeUndefined();
});

it('uses WeakRef only for isolation scopes', () => {
it('uses WeakRef only for isolation scope, stores scope directly', () => {
const span = createMockSpan();
const scope = new Scope();
const isolationScope = new Scope();

setCapturedScopesOnSpan(span, scope, isolationScope);

// Check that only isolation scope is wrapped with WeakRef
const spanWithScopes = span as any;
expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope stored directly
expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef); // Isolation scope wrapped
expect(spanWithScopes._sentryScope).toBe(scope);
expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef);

// Verify we can still retrieve the scopes
const retrieved = getCapturedScopesOnSpan(span);
Expand All@@ -77,14 +76,8 @@ describe('tracing utils', () => {

// Check that both scopes are stored directly when WeakRef is not available
const spanWithScopes = span as any;
expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope always stored directly
expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope); // Isolation scope falls back to direct storage

// When WeakRef is available, ensure regular scope is not wrapped but isolation scope would be
if (originalWeakRef) {
expect(spanWithScopes._sentryScope).not.toBeInstanceOf(originalWeakRef);
expect(spanWithScopes._sentryIsolationScope).not.toBeInstanceOf(originalWeakRef);
}
expect(spanWithScopes._sentryScope).toBe(scope);
expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope);

// Verify we can still retrieve the scopes
const retrieved = getCapturedScopesOnSpan(span);
Expand All@@ -96,46 +89,42 @@ describe('tracing utils', () => {
}
});

it('handles WeakRef deref returning undefined gracefully', () => {
it('handles WeakRef deref returning undefined gracefully for isolation scope', () => {
const span = createMockSpan();
const scope = new Scope();
const isolationScope = new Scope();

setCapturedScopesOnSpan(span, scope, isolationScope);

// Mock WeakRef.deref to return undefined for isolation scope (simulating garbage collection)
// Regular scope is stored directly, so it should always be available
// Mock isolation scope WeakRef to return undefined (simulating garbage collection)
const spanWithScopes = span as any;
const mockIsolationScopeWeakRef = {
deref: vi.fn().mockReturnValue(undefined),
};

// Keep the regular scope as is (stored directly)
// Only replace the isolation scope with a mock WeakRef
spanWithScopes._sentryIsolationScope = mockIsolationScopeWeakRef;

const retrieved = getCapturedScopesOnSpan(span);
expect(retrieved.scope).toBe(scope); // Regular scope should still be available
expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to GC
expect(retrieved.scope).toBe(scope);
expect(retrieved.isolationScope).toBeUndefined();
expect(mockIsolationScopeWeakRef.deref).toHaveBeenCalled();
});

it('handles corrupted WeakRef objects gracefully', () => {
it('handles corrupted WeakRef objects gracefully for isolation scope', () => {
const span = createMockSpan();
const scope = new Scope();

// Set up a regular scope (stored directly) and a corrupted isolation scope WeakRef
const spanWithScopes = span as any;
spanWithScopes._sentryScope = scope; // Regular scope stored directly
spanWithScopes._sentryScope = scope;
spanWithScopes._sentryIsolationScope = {
deref: vi.fn().mockImplementation(() => {
throw new Error('WeakRef deref failed');
}),
};

const retrieved = getCapturedScopesOnSpan(span);
expect(retrieved.scope).toBe(scope); // Regular scope should still be available
expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to error
expect(retrieved.scope).toBe(scope);
expect(retrieved.isolationScope).toBeUndefined();
});

it('preserves scope data when using WeakRef', () => {
Expand Down
31 changes: 9 additions & 22 deletions packages/opentelemetry/src/spanExporter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
getCapturedScopesOnSpan,
getDynamicSamplingContextFromSpan,
getStatusMessage,
LRUMap,
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
Expand All@@ -41,6 +42,7 @@ type SpanNodeCompleted = SpanNode & { span: ReadableSpan };

const MAX_SPAN_COUNT = 1000;
const DEFAULT_TIMEOUT = 300; // 5 min
const SENT_SPANS_MAX_SIZE = 10000;

interface FinishedSpanBucket {
timestampInS: number;
Expand DownExpand Up@@ -71,9 +73,7 @@ export class SentrySpanExporter {
private _finishedSpanBucketSize: number;
private _spansToBucketEntry: WeakMap<ReadableSpan, FinishedSpanBucket>;
private _lastCleanupTimestampInS: number;
// Essentially a a set of span ids that are already sent. The values are expiration
// times in this cache so we don't hold onto them indefinitely.
private _sentSpans: Map<string, number>;
private _sentSpans: LRUMap<string, number>;
/* Internally, we use a debounced flush to give some wiggle room to the span processor to accumulate more spans. */
private _debouncedFlush: ReturnType<typeof debounce>;

Expand All@@ -85,7 +85,7 @@ export class SentrySpanExporter {
this._finishedSpanBuckets = new Array(this._finishedSpanBucketSize).fill(undefined);
this._lastCleanupTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000);
this._spansToBucketEntry = new WeakMap();
this._sentSpans = new Map<string, number>();
this._sentSpans = new LRUMap<string, number>(SENT_SPANS_MAX_SIZE);
this._debouncedFlush = debounce(this.flush.bind(this), 1, { maxWait: 100 });
}

Expand DownExpand Up@@ -124,7 +124,7 @@ export class SentrySpanExporter {

// If the span doesn't have a local parent ID (it's a root span), we're gonna flush all the ended spans
const localParentId = getLocalParentId(span);
if (!localParentId || this._sentSpans.has(localParentId)) {
if (!localParentId || this._sentSpans.get(localParentId)) {
this._debouncedFlush();
}
}
Expand All@@ -137,7 +137,6 @@ export class SentrySpanExporter {
public flush(): void {
const finishedSpans = this._finishedSpanBuckets.flatMap(bucket => (bucket ? Array.from(bucket.spans) : []));

this._flushSentSpanCache();
const sentSpans = this._maybeSend(finishedSpans);

const sentSpanCount = sentSpans.size;
Expand All@@ -147,15 +146,14 @@ export class SentrySpanExporter {
`SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} spans are waiting for their parent spans to finish`,
);

const expirationDate = _INTERNAL_safeDateNow() + DEFAULT_TIMEOUT * 1000;

for (const span of sentSpans) {
this._sentSpans.set(span.spanContext().spanId, expirationDate);
this._sentSpans.set(span.spanContext().spanId, 1);
const bucketEntry = this._spansToBucketEntry.get(span);
if (bucketEntry) {
bucketEntry.spans.delete(span);
}
}

// Cancel a pending debounced flush, if there is one
// This can be relevant if we directly flush, circumventing the debounce
// in that case, we want to cancel any pending debounced flush
Expand DownExpand Up@@ -193,7 +191,7 @@ export class SentrySpanExporter {
const transactionEvent = createTransactionForOtelSpan(span);

// Add an attribute to the transaction event to indicate that this transaction is an orphaned transaction
if (root.parentNode && this._sentSpans.has(root.parentNode.id)) {
if (root.parentNode && this._sentSpans.get(root.parentNode.id)) {
const traceData = transactionEvent.contexts?.trace?.data;
if (traceData) {
traceData['sentry.parent_span_already_sent'] = true;
Expand DownExpand Up@@ -235,20 +233,9 @@ export class SentrySpanExporter {
return sentSpans;
}

/** Remove "expired" span id entries from the _sentSpans cache. */
private _flushSentSpanCache(): void {
const currentTimestamp = _INTERNAL_safeDateNow();
// Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297
for (const [spanId, expirationTime] of this._sentSpans.entries()) {
if (expirationTime <= currentTimestamp) {
this._sentSpans.delete(spanId);
}
}
}

/** Check if a node is a completed root node or a node whose parent has already been sent */
private _nodeIsCompletedRootNodeOrHasSentParent(node: SpanNode): node is SpanNodeCompleted {
return !!node.span && (!node.parentNode || this._sentSpans.has(node.parentNode.id));
return !!node.span && (!node.parentNode || !!this._sentSpans.get(node.parentNode.id));
}

/** Get all completed root nodes from a list of nodes */
Expand Down
25 changes: 7 additions & 18 deletions packages/opentelemetry/test/integration/transactions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -612,7 +612,7 @@ describe('Integration | Transactions', () => {
expect(finishedSpans.length).toBe(0);
});

it('discards child spans that are finished after 5 minutes their parent span has been sent', async () => {
it('sends child spans that are finished after 5 minutes as orphaned transactions', async () => {
const timeout = 5 * 60 * 1000;
const now = Date.now();
vi.useFakeTimers();
Expand All@@ -631,14 +631,6 @@ describe('Integration | Transactions', () => {
},
});

const spanProcessor = getSpanProcessor();

const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined;

if (!exporter) {
throw new Error('No exporter found, aborting test...');
}

startSpanManual({ name: 'test name' }, async span => {
const subSpan = startInactiveSpan({ name: 'inner span 1' });
subSpan.end();
Expand All@@ -654,18 +646,15 @@ describe('Integration | Transactions', () => {

vi.advanceTimersByTime(timeout + 2);

expect(transactions).toHaveLength(1);
expect(transactions).toHaveLength(2);
expect(transactions[0]?.spans).toHaveLength(1);

// subSpan2 is pending (and will eventually be cleaned up)
const finishedSpans: any = [];
exporter['_finishedSpanBuckets'].forEach(bucket => {
if (bucket) {
finishedSpans.push(...bucket.spans);
}
expect(transactions[1]?.transaction).toBe('inner span 2');
expect(transactions[1]?.contexts?.trace?.data).toEqual({
'sentry.parent_span_already_sent': true,
'sentry.origin': 'manual',
'sentry.source': 'custom',
});
expect(finishedSpans.length).toBe(1);
expect(finishedSpans[0]?.name).toBe('inner span 2');
});

it('only considers sent spans, not finished spans, for flushing orphaned spans of sent spans', async () => {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
8 changes: 5 additions & 3 deletions packages/core/src/utils/spanOnScope.ts
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
import type { Scope } from '../scope';
import type { Span } from '../types/span';
import { addNonEnumerableProperty } from '../utils/object';
import { derefWeakRef, makeWeakRef, type MaybeWeakRef } from './weakRef';

const SCOPE_SPAN_FIELD = '_sentrySpan';

type ScopeWithMaybeSpan = Scope & {
[SCOPE_SPAN_FIELD]?: Span;
[SCOPE_SPAN_FIELD]?: MaybeWeakRef<Span>;
};

/**
Expand All@@ -14,7 +15,8 @@ type ScopeWithMaybeSpan = Scope & {
*/
export function _setSpanForScope(scope: Scope, span: Span | undefined): void {
if (span) {
addNonEnumerableProperty(scope as ScopeWithMaybeSpan, SCOPE_SPAN_FIELD, span);
// Use WeakRef to avoid circular reference with span holding scope
addNonEnumerableProperty(scope as ScopeWithMaybeSpan, SCOPE_SPAN_FIELD, makeWeakRef(span));
} else {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete (scope as ScopeWithMaybeSpan)[SCOPE_SPAN_FIELD];
Expand All@@ -26,5 +28,5 @@ export function _setSpanForScope(scope: Scope, span: Span | undefined): void {
* NOTE: This should NOT be used directly, but is only used internally by the trace methods.
*/
export function _getSpanForScope(scope: ScopeWithMaybeSpan): Span | undefined {
return scope[SCOPE_SPAN_FIELD];
return derefWeakRef(scope[SCOPE_SPAN_FIELD]);
}
37 changes: 13 additions & 24 deletions packages/core/test/lib/tracing/utils.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,17 +44,16 @@ describe('tracing utils', () => {
expect(retrieved.isolationScope).toBeUndefined();
});

it('uses WeakRef only for isolation scopes', () => {
it('uses WeakRef only for isolation scope, stores scope directly', () => {
const span = createMockSpan();
const scope = new Scope();
const isolationScope = new Scope();

setCapturedScopesOnSpan(span, scope, isolationScope);

// Check that only isolation scope is wrapped with WeakRef
const spanWithScopes = span as any;
expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope stored directly
expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef); // Isolation scope wrapped
expect(spanWithScopes._sentryScope).toBe(scope);
expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef);

// Verify we can still retrieve the scopes
const retrieved = getCapturedScopesOnSpan(span);
Expand All@@ -77,14 +76,8 @@ describe('tracing utils', () => {

// Check that both scopes are stored directly when WeakRef is not available
const spanWithScopes = span as any;
expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope always stored directly
expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope); // Isolation scope falls back to direct storage

// When WeakRef is available, ensure regular scope is not wrapped but isolation scope would be
if (originalWeakRef) {
expect(spanWithScopes._sentryScope).not.toBeInstanceOf(originalWeakRef);
expect(spanWithScopes._sentryIsolationScope).not.toBeInstanceOf(originalWeakRef);
}
expect(spanWithScopes._sentryScope).toBe(scope);
expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope);

// Verify we can still retrieve the scopes
const retrieved = getCapturedScopesOnSpan(span);
Expand All@@ -96,46 +89,42 @@ describe('tracing utils', () => {
}
});

it('handles WeakRef deref returning undefined gracefully', () => {
it('handles WeakRef deref returning undefined gracefully for isolation scope', () => {
const span = createMockSpan();
const scope = new Scope();
const isolationScope = new Scope();

setCapturedScopesOnSpan(span, scope, isolationScope);

// Mock WeakRef.deref to return undefined for isolation scope (simulating garbage collection)
// Regular scope is stored directly, so it should always be available
// Mock isolation scope WeakRef to return undefined (simulating garbage collection)
const spanWithScopes = span as any;
const mockIsolationScopeWeakRef = {
deref: vi.fn().mockReturnValue(undefined),
};

// Keep the regular scope as is (stored directly)
// Only replace the isolation scope with a mock WeakRef
spanWithScopes._sentryIsolationScope = mockIsolationScopeWeakRef;

const retrieved = getCapturedScopesOnSpan(span);
expect(retrieved.scope).toBe(scope); // Regular scope should still be available
expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to GC
expect(retrieved.scope).toBe(scope);
expect(retrieved.isolationScope).toBeUndefined();
expect(mockIsolationScopeWeakRef.deref).toHaveBeenCalled();
});

it('handles corrupted WeakRef objects gracefully', () => {
it('handles corrupted WeakRef objects gracefully for isolation scope', () => {
const span = createMockSpan();
const scope = new Scope();

// Set up a regular scope (stored directly) and a corrupted isolation scope WeakRef
const spanWithScopes = span as any;
spanWithScopes._sentryScope = scope; // Regular scope stored directly
spanWithScopes._sentryScope = scope;
spanWithScopes._sentryIsolationScope = {
deref: vi.fn().mockImplementation(() => {
throw new Error('WeakRef deref failed');
}),
};

const retrieved = getCapturedScopesOnSpan(span);
expect(retrieved.scope).toBe(scope); // Regular scope should still be available
expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to error
expect(retrieved.scope).toBe(scope);
expect(retrieved.isolationScope).toBeUndefined();
});

it('preserves scope data when using WeakRef', () => {
Expand Down
31 changes: 9 additions & 22 deletions packages/opentelemetry/src/spanExporter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
getCapturedScopesOnSpan,
getDynamicSamplingContextFromSpan,
getStatusMessage,
LRUMap,
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
Expand All@@ -41,6 +42,7 @@ type SpanNodeCompleted = SpanNode & { span: ReadableSpan };

const MAX_SPAN_COUNT = 1000;
const DEFAULT_TIMEOUT = 300; // 5 min
const SENT_SPANS_MAX_SIZE = 10000;

interface FinishedSpanBucket {
timestampInS: number;
Expand DownExpand Up@@ -71,9 +73,7 @@ export class SentrySpanExporter {
private _finishedSpanBucketSize: number;
private _spansToBucketEntry: WeakMap<ReadableSpan, FinishedSpanBucket>;
private _lastCleanupTimestampInS: number;
// Essentially a a set of span ids that are already sent. The values are expiration
// times in this cache so we don't hold onto them indefinitely.
private _sentSpans: Map<string, number>;
private _sentSpans: LRUMap<string, number>;
/* Internally, we use a debounced flush to give some wiggle room to the span processor to accumulate more spans. */
private _debouncedFlush: ReturnType<typeof debounce>;

Expand All@@ -85,7 +85,7 @@ export class SentrySpanExporter {
this._finishedSpanBuckets = new Array(this._finishedSpanBucketSize).fill(undefined);
this._lastCleanupTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000);
this._spansToBucketEntry = new WeakMap();
this._sentSpans = new Map<string, number>();
this._sentSpans = new LRUMap<string, number>(SENT_SPANS_MAX_SIZE);
this._debouncedFlush = debounce(this.flush.bind(this), 1, { maxWait: 100 });
}

Expand DownExpand Up@@ -124,7 +124,7 @@ export class SentrySpanExporter {

// If the span doesn't have a local parent ID (it's a root span), we're gonna flush all the ended spans
const localParentId = getLocalParentId(span);
if (!localParentId || this._sentSpans.has(localParentId)) {
if (!localParentId || this._sentSpans.get(localParentId)) {
this._debouncedFlush();
}
}
Expand All@@ -137,7 +137,6 @@ export class SentrySpanExporter {
public flush(): void {
const finishedSpans = this._finishedSpanBuckets.flatMap(bucket => (bucket ? Array.from(bucket.spans) : []));

this._flushSentSpanCache();
const sentSpans = this._maybeSend(finishedSpans);

const sentSpanCount = sentSpans.size;
Expand All@@ -147,15 +146,14 @@ export class SentrySpanExporter {
`SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} spans are waiting for their parent spans to finish`,
);

const expirationDate = _INTERNAL_safeDateNow() + DEFAULT_TIMEOUT * 1000;

for (const span of sentSpans) {
this._sentSpans.set(span.spanContext().spanId, expirationDate);
this._sentSpans.set(span.spanContext().spanId, 1);
const bucketEntry = this._spansToBucketEntry.get(span);
if (bucketEntry) {
bucketEntry.spans.delete(span);
}
}

// Cancel a pending debounced flush, if there is one
// This can be relevant if we directly flush, circumventing the debounce
// in that case, we want to cancel any pending debounced flush
Expand DownExpand Up@@ -193,7 +191,7 @@ export class SentrySpanExporter {
const transactionEvent = createTransactionForOtelSpan(span);

// Add an attribute to the transaction event to indicate that this transaction is an orphaned transaction
if (root.parentNode && this._sentSpans.has(root.parentNode.id)) {
if (root.parentNode && this._sentSpans.get(root.parentNode.id)) {
const traceData = transactionEvent.contexts?.trace?.data;
if (traceData) {
traceData['sentry.parent_span_already_sent'] = true;
Expand DownExpand Up@@ -235,20 +233,9 @@ export class SentrySpanExporter {
return sentSpans;
}

/** Remove "expired" span id entries from the _sentSpans cache. */
private _flushSentSpanCache(): void {
const currentTimestamp = _INTERNAL_safeDateNow();
// Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297
for (const [spanId, expirationTime] of this._sentSpans.entries()) {
if (expirationTime <= currentTimestamp) {
this._sentSpans.delete(spanId);
}
}
}

/** Check if a node is a completed root node or a node whose parent has already been sent */
private _nodeIsCompletedRootNodeOrHasSentParent(node: SpanNode): node is SpanNodeCompleted {
return !!node.span && (!node.parentNode || this._sentSpans.has(node.parentNode.id));
return !!node.span && (!node.parentNode || !!this._sentSpans.get(node.parentNode.id));
}

/** Get all completed root nodes from a list of nodes */
Expand Down
25 changes: 7 additions & 18 deletions packages/opentelemetry/test/integration/transactions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -612,7 +612,7 @@ describe('Integration | Transactions', () => {
expect(finishedSpans.length).toBe(0);
});

it('discards child spans that are finished after 5 minutes their parent span has been sent', async () => {
it('sends child spans that are finished after 5 minutes as orphaned transactions', async () => {
const timeout = 5 * 60 * 1000;
const now = Date.now();
vi.useFakeTimers();
Expand All@@ -631,14 +631,6 @@ describe('Integration | Transactions', () => {
},
});

const spanProcessor = getSpanProcessor();

const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined;

if (!exporter) {
throw new Error('No exporter found, aborting test...');
}

startSpanManual({ name: 'test name' }, async span => {
const subSpan = startInactiveSpan({ name: 'inner span 1' });
subSpan.end();
Expand All@@ -654,18 +646,15 @@ describe('Integration | Transactions', () => {

vi.advanceTimersByTime(timeout + 2);

expect(transactions).toHaveLength(1);
expect(transactions).toHaveLength(2);
expect(transactions[0]?.spans).toHaveLength(1);

// subSpan2 is pending (and will eventually be cleaned up)
const finishedSpans: any = [];
exporter['_finishedSpanBuckets'].forEach(bucket => {
if (bucket) {
finishedSpans.push(...bucket.spans);
}
expect(transactions[1]?.transaction).toBe('inner span 2');
expect(transactions[1]?.contexts?.trace?.data).toEqual({
'sentry.parent_span_already_sent': true,
'sentry.origin': 'manual',
'sentry.source': 'custom',
});
expect(finishedSpans.length).toBe(1);
expect(finishedSpans[0]?.name).toBe('inner span 2');
});

it('only considers sent spans, not finished spans, for flushing orphaned spans of sent spans', async () => {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
8 changes: 5 additions & 3 deletions packages/core/src/utils/spanOnScope.ts
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
import type { Scope } from '../scope';
import type { Span } from '../types/span';
import { addNonEnumerableProperty } from '../utils/object';
import { derefWeakRef, makeWeakRef, type MaybeWeakRef } from './weakRef';

const SCOPE_SPAN_FIELD = '_sentrySpan';

type ScopeWithMaybeSpan = Scope & {
[SCOPE_SPAN_FIELD]?: Span;
[SCOPE_SPAN_FIELD]?: MaybeWeakRef<Span>;
};

/**
Expand All@@ -14,7 +15,8 @@ type ScopeWithMaybeSpan = Scope & {
*/
export function _setSpanForScope(scope: Scope, span: Span | undefined): void {
if (span) {
addNonEnumerableProperty(scope as ScopeWithMaybeSpan, SCOPE_SPAN_FIELD, span);
// Use WeakRef to avoid circular reference with span holding scope
addNonEnumerableProperty(scope as ScopeWithMaybeSpan, SCOPE_SPAN_FIELD, makeWeakRef(span));
} else {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete (scope as ScopeWithMaybeSpan)[SCOPE_SPAN_FIELD];
Expand All@@ -26,5 +28,5 @@ export function _setSpanForScope(scope: Scope, span: Span | undefined): void {
* NOTE: This should NOT be used directly, but is only used internally by the trace methods.
*/
export function _getSpanForScope(scope: ScopeWithMaybeSpan): Span | undefined {
return scope[SCOPE_SPAN_FIELD];
return derefWeakRef(scope[SCOPE_SPAN_FIELD]);
}
37 changes: 13 additions & 24 deletions packages/core/test/lib/tracing/utils.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,17 +44,16 @@ describe('tracing utils', () => {
expect(retrieved.isolationScope).toBeUndefined();
});

it('uses WeakRef only for isolation scopes', () => {
it('uses WeakRef only for isolation scope, stores scope directly', () => {
const span = createMockSpan();
const scope = new Scope();
const isolationScope = new Scope();

setCapturedScopesOnSpan(span, scope, isolationScope);

// Check that only isolation scope is wrapped with WeakRef
const spanWithScopes = span as any;
expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope stored directly
expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef); // Isolation scope wrapped
expect(spanWithScopes._sentryScope).toBe(scope);
expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef);

// Verify we can still retrieve the scopes
const retrieved = getCapturedScopesOnSpan(span);
Expand All@@ -77,14 +76,8 @@ describe('tracing utils', () => {

// Check that both scopes are stored directly when WeakRef is not available
const spanWithScopes = span as any;
expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope always stored directly
expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope); // Isolation scope falls back to direct storage

// When WeakRef is available, ensure regular scope is not wrapped but isolation scope would be
if (originalWeakRef) {
expect(spanWithScopes._sentryScope).not.toBeInstanceOf(originalWeakRef);
expect(spanWithScopes._sentryIsolationScope).not.toBeInstanceOf(originalWeakRef);
}
expect(spanWithScopes._sentryScope).toBe(scope);
expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope);

// Verify we can still retrieve the scopes
const retrieved = getCapturedScopesOnSpan(span);
Expand All@@ -96,46 +89,42 @@ describe('tracing utils', () => {
}
});

it('handles WeakRef deref returning undefined gracefully', () => {
it('handles WeakRef deref returning undefined gracefully for isolation scope', () => {
const span = createMockSpan();
const scope = new Scope();
const isolationScope = new Scope();

setCapturedScopesOnSpan(span, scope, isolationScope);

// Mock WeakRef.deref to return undefined for isolation scope (simulating garbage collection)
// Regular scope is stored directly, so it should always be available
// Mock isolation scope WeakRef to return undefined (simulating garbage collection)
const spanWithScopes = span as any;
const mockIsolationScopeWeakRef = {
deref: vi.fn().mockReturnValue(undefined),
};

// Keep the regular scope as is (stored directly)
// Only replace the isolation scope with a mock WeakRef
spanWithScopes._sentryIsolationScope = mockIsolationScopeWeakRef;

const retrieved = getCapturedScopesOnSpan(span);
expect(retrieved.scope).toBe(scope); // Regular scope should still be available
expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to GC
expect(retrieved.scope).toBe(scope);
expect(retrieved.isolationScope).toBeUndefined();
expect(mockIsolationScopeWeakRef.deref).toHaveBeenCalled();
});

it('handles corrupted WeakRef objects gracefully', () => {
it('handles corrupted WeakRef objects gracefully for isolation scope', () => {
const span = createMockSpan();
const scope = new Scope();

// Set up a regular scope (stored directly) and a corrupted isolation scope WeakRef
const spanWithScopes = span as any;
spanWithScopes._sentryScope = scope; // Regular scope stored directly
spanWithScopes._sentryScope = scope;
spanWithScopes._sentryIsolationScope = {
deref: vi.fn().mockImplementation(() => {
throw new Error('WeakRef deref failed');
}),
};

const retrieved = getCapturedScopesOnSpan(span);
expect(retrieved.scope).toBe(scope); // Regular scope should still be available
expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to error
expect(retrieved.scope).toBe(scope);
expect(retrieved.isolationScope).toBeUndefined();
});

it('preserves scope data when using WeakRef', () => {
Expand Down
31 changes: 9 additions & 22 deletions packages/opentelemetry/src/spanExporter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
getCapturedScopesOnSpan,
getDynamicSamplingContextFromSpan,
getStatusMessage,
LRUMap,
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
Expand All@@ -41,6 +42,7 @@ type SpanNodeCompleted = SpanNode & { span: ReadableSpan };

const MAX_SPAN_COUNT = 1000;
const DEFAULT_TIMEOUT = 300; // 5 min
const SENT_SPANS_MAX_SIZE = 10000;

interface FinishedSpanBucket {
timestampInS: number;
Expand DownExpand Up@@ -71,9 +73,7 @@ export class SentrySpanExporter {
private _finishedSpanBucketSize: number;
private _spansToBucketEntry: WeakMap<ReadableSpan, FinishedSpanBucket>;
private _lastCleanupTimestampInS: number;
// Essentially a a set of span ids that are already sent. The values are expiration
// times in this cache so we don't hold onto them indefinitely.
private _sentSpans: Map<string, number>;
private _sentSpans: LRUMap<string, number>;
/* Internally, we use a debounced flush to give some wiggle room to the span processor to accumulate more spans. */
private _debouncedFlush: ReturnType<typeof debounce>;

Expand All@@ -85,7 +85,7 @@ export class SentrySpanExporter {
this._finishedSpanBuckets = new Array(this._finishedSpanBucketSize).fill(undefined);
this._lastCleanupTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000);
this._spansToBucketEntry = new WeakMap();
this._sentSpans = new Map<string, number>();
this._sentSpans = new LRUMap<string, number>(SENT_SPANS_MAX_SIZE);
this._debouncedFlush = debounce(this.flush.bind(this), 1, { maxWait: 100 });
}

Expand DownExpand Up@@ -124,7 +124,7 @@ export class SentrySpanExporter {

// If the span doesn't have a local parent ID (it's a root span), we're gonna flush all the ended spans
const localParentId = getLocalParentId(span);
if (!localParentId || this._sentSpans.has(localParentId)) {
if (!localParentId || this._sentSpans.get(localParentId)) {
this._debouncedFlush();
}
}
Expand All@@ -137,7 +137,6 @@ export class SentrySpanExporter {
public flush(): void {
const finishedSpans = this._finishedSpanBuckets.flatMap(bucket => (bucket ? Array.from(bucket.spans) : []));

this._flushSentSpanCache();
const sentSpans = this._maybeSend(finishedSpans);

const sentSpanCount = sentSpans.size;
Expand All@@ -147,15 +146,14 @@ export class SentrySpanExporter {
`SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} spans are waiting for their parent spans to finish`,
);

const expirationDate = _INTERNAL_safeDateNow() + DEFAULT_TIMEOUT * 1000;

for (const span of sentSpans) {
this._sentSpans.set(span.spanContext().spanId, expirationDate);
this._sentSpans.set(span.spanContext().spanId, 1);
const bucketEntry = this._spansToBucketEntry.get(span);
if (bucketEntry) {
bucketEntry.spans.delete(span);
}
}

// Cancel a pending debounced flush, if there is one
// This can be relevant if we directly flush, circumventing the debounce
// in that case, we want to cancel any pending debounced flush
Expand DownExpand Up@@ -193,7 +191,7 @@ export class SentrySpanExporter {
const transactionEvent = createTransactionForOtelSpan(span);

// Add an attribute to the transaction event to indicate that this transaction is an orphaned transaction
if (root.parentNode && this._sentSpans.has(root.parentNode.id)) {
if (root.parentNode && this._sentSpans.get(root.parentNode.id)) {
const traceData = transactionEvent.contexts?.trace?.data;
if (traceData) {
traceData['sentry.parent_span_already_sent'] = true;
Expand DownExpand Up@@ -235,20 +233,9 @@ export class SentrySpanExporter {
return sentSpans;
}

/** Remove "expired" span id entries from the _sentSpans cache. */
private _flushSentSpanCache(): void {
const currentTimestamp = _INTERNAL_safeDateNow();
// Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297
for (const [spanId, expirationTime] of this._sentSpans.entries()) {
if (expirationTime <= currentTimestamp) {
this._sentSpans.delete(spanId);
}
}
}

/** Check if a node is a completed root node or a node whose parent has already been sent */
private _nodeIsCompletedRootNodeOrHasSentParent(node: SpanNode): node is SpanNodeCompleted {
return !!node.span && (!node.parentNode || this._sentSpans.has(node.parentNode.id));
return !!node.span && (!node.parentNode || !!this._sentSpans.get(node.parentNode.id));
}

/** Get all completed root nodes from a list of nodes */
Expand Down
25 changes: 7 additions & 18 deletions packages/opentelemetry/test/integration/transactions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -612,7 +612,7 @@ describe('Integration | Transactions', () => {
expect(finishedSpans.length).toBe(0);
});

it('discards child spans that are finished after 5 minutes their parent span has been sent', async () => {
it('sends child spans that are finished after 5 minutes as orphaned transactions', async () => {
const timeout = 5 * 60 * 1000;
const now = Date.now();
vi.useFakeTimers();
Expand All@@ -631,14 +631,6 @@ describe('Integration | Transactions', () => {
},
});

const spanProcessor = getSpanProcessor();

const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined;

if (!exporter) {
throw new Error('No exporter found, aborting test...');
}

startSpanManual({ name: 'test name' }, async span => {
const subSpan = startInactiveSpan({ name: 'inner span 1' });
subSpan.end();
Expand All@@ -654,18 +646,15 @@ describe('Integration | Transactions', () => {

vi.advanceTimersByTime(timeout + 2);

expect(transactions).toHaveLength(1);
expect(transactions).toHaveLength(2);
expect(transactions[0]?.spans).toHaveLength(1);

// subSpan2 is pending (and will eventually be cleaned up)
const finishedSpans: any = [];
exporter['_finishedSpanBuckets'].forEach(bucket => {
if (bucket) {
finishedSpans.push(...bucket.spans);
}
expect(transactions[1]?.transaction).toBe('inner span 2');
expect(transactions[1]?.contexts?.trace?.data).toEqual({
'sentry.parent_span_already_sent': true,
'sentry.origin': 'manual',
'sentry.source': 'custom',
});
expect(finishedSpans.length).toBe(1);
expect(finishedSpans[0]?.name).toBe('inner span 2');
});

it('only considers sent spans, not finished spans, for flushing orphaned spans of sent spans', async () => {
Expand Down
Loading