Skip to content

Commit 3f840b4

Browse files
committed
Buffer with global weakmap and save on spanEnd
1 parent 49b4b1c commit 3f840b4

File tree

8 files changed

+168
-56
lines changed

8 files changed

+168
-56
lines changed

dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts

Lines changed: 38 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -548,57 +548,57 @@ describe('Integration | Transactions', () => {
548548
expect(finishedSpans.length).toBe(0);
549549
});
550550

551-
it('collects child spans that are finished within 5 minutes their parent span has been sent', async () => {
552-
const timeout = 5 * 60 * 1000;
553-
const now = Date.now();
554-
vi.useFakeTimers();
555-
vi.setSystemTime(now);
551+
it('collects child spans that are finished within 5 minutes their parent span has been sent', async () => {
552+
const timeout = 5 * 60 * 1000;
553+
const now = Date.now();
554+
vi.useFakeTimers();
555+
vi.setSystemTime(now);
556556

557-
const logs: unknown[] = [];
558-
vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg));
557+
const logs: unknown[] = [];
558+
vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg));
559559

560-
const transactions: Event[] = [];
560+
const transactions: Event[] = [];
561561

562-
mockSdkInit({
563-
tracesSampleRate: 1,
564-
beforeSendTransaction: event => {
565-
transactions.push(event);
566-
return null;
567-
},
568-
});
562+
mockSdkInit({
563+
tracesSampleRate: 1,
564+
beforeSendTransaction: event => {
565+
transactions.push(event);
566+
return null;
567+
},
568+
});
569569

570-
const provider = getProvider();
571-
const spanProcessor = getSpanProcessor();
570+
const provider = getProvider();
571+
const spanProcessor = getSpanProcessor();
572572

573-
const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined;
573+
const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined;
574574

575-
if (!exporter) {
576-
throw new Error('No exporter found, aborting test...');
577-
}
575+
if (!exporter) {
576+
throw new Error('No exporter found, aborting test...');
577+
}
578578

579-
startSpanManual({ name: 'test name' }, async span => {
580-
const subSpan = startInactiveSpan({ name: 'inner span 1' });
581-
subSpan.end();
579+
startSpanManual({ name: 'test name' }, async span => {
580+
const subSpan = startInactiveSpan({ name: 'inner span 1' });
581+
subSpan.end();
582582

583-
const subSpan2 = startInactiveSpan({ name: 'inner span 2' });
583+
const subSpan2 = startInactiveSpan({ name: 'inner span 2' });
584584

585-
span.end();
585+
span.end();
586586

587-
setTimeout(() => {
588-
subSpan2.end();
589-
}, timeout - 2);
590-
});
587+
setTimeout(() => {
588+
subSpan2.end();
589+
}, timeout - 2);
590+
});
591591

592-
vi.advanceTimersByTime(timeout - 1);
592+
vi.advanceTimersByTime(timeout - 1);
593593

594-
expect(transactions).toHaveLength(2);
595-
expect(transactions[0]?.spans).toHaveLength(1);
594+
expect(transactions).toHaveLength(2);
595+
expect(transactions[0]?.spans).toHaveLength(1);
596596

597-
const finishedSpans: any = exporter['_finishedSpanBuckets'].flatMap(bucket =>
598-
bucket ? Array.from(bucket.spans) : [],
599-
);
600-
expect(finishedSpans.length).toBe(0);
601-
});
597+
const finishedSpans: any = exporter['_finishedSpanBuckets'].flatMap(bucket =>
598+
bucket ? Array.from(bucket.spans) : [],
599+
);
600+
expect(finishedSpans.length).toBe(0);
601+
});
602602

603603
it('discards child spans that are finished after 5 minutes their parent span has been sent', async () => {
604604
const timeout = 5 * 60 * 1000;

packages/browser/src/integrations/featureFlags/featureFlagsIntegration.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import type { Client, Event, EventHint, Integration, IntegrationFn } from '@sentry/core';
1+
import type { Client, Event, EventHint, Integration, IntegrationFn, Span } from '@sentry/core';
22
import { defineIntegration } from '@sentry/core';
3-
import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../utils/featureFlags';
3+
import {
4+
bufferSpanFeatureFlag,
5+
copyFlagsFromScopeToEvent,
6+
freezeSpanFeatureFlags,
7+
insertFlagToScope,
8+
} from '../../utils/featureFlags';
49

510
export interface FeatureFlagsIntegration extends Integration {
611
addFeatureFlag: (name: string, value: unknown) => void;
@@ -35,12 +40,19 @@ export const featureFlagsIntegration = defineIntegration(() => {
3540
return {
3641
name: 'FeatureFlags',
3742

43+
setup(client: Client) {
44+
client.on('spanEnd', (span: Span) => {
45+
freezeSpanFeatureFlags(span);
46+
});
47+
},
48+
3849
processEvent(event: Event, _hint: EventHint, _client: Client): Event {
3950
return copyFlagsFromScopeToEvent(event);
4051
},
4152

4253
addFeatureFlag(name: string, value: unknown): void {
4354
insertFlagToScope(name, value);
55+
bufferSpanFeatureFlag(name, value);
4456
},
4557
};
4658
}) as IntegrationFn<FeatureFlagsIntegration>;

packages/browser/src/integrations/featureFlags/launchdarkly/integration.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core';
1+
import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core';
22
import { defineIntegration } from '@sentry/core';
3-
import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags';
3+
import {
4+
bufferSpanFeatureFlag,
5+
copyFlagsFromScopeToEvent,
6+
freezeSpanFeatureFlags,
7+
insertFlagToScope,
8+
} from '../../../utils/featureFlags';
49
import type { LDContext, LDEvaluationDetail, LDInspectionFlagUsedHandler } from './types';
510

611
/**
@@ -22,6 +27,12 @@ export const launchDarklyIntegration = defineIntegration(() => {
2227
return {
2328
name: 'LaunchDarkly',
2429

30+
setup(client: Client) {
31+
client.on('spanEnd', (span: Span) => {
32+
freezeSpanFeatureFlags(span);
33+
});
34+
},
35+
2536
processEvent(event: Event, _hint: EventHint, _client: Client): Event {
2637
return copyFlagsFromScopeToEvent(event);
2738
},
@@ -46,6 +57,7 @@ export function buildLaunchDarklyFlagUsedHandler(): LDInspectionFlagUsedHandler
4657
*/
4758
method: (flagKey: string, flagDetail: LDEvaluationDetail, _context: LDContext) => {
4859
insertFlagToScope(flagKey, flagDetail.value);
60+
bufferSpanFeatureFlag(flagKey, flagDetail.value);
4961
},
5062
};
5163
}

packages/browser/src/integrations/featureFlags/openfeature/integration.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,26 @@
55
* Add the integration hook to your OpenFeature object.
66
* - OpenFeature.getClient().addHooks(new OpenFeatureIntegrationHook());
77
*/
8-
import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core';
8+
import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core';
99
import { defineIntegration } from '@sentry/core';
10-
import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags';
10+
import {
11+
bufferSpanFeatureFlag,
12+
copyFlagsFromScopeToEvent,
13+
freezeSpanFeatureFlags,
14+
insertFlagToScope,
15+
} from '../../../utils/featureFlags';
1116
import type { EvaluationDetails, HookContext, HookHints, JsonValue, OpenFeatureHook } from './types';
1217

1318
export const openFeatureIntegration = defineIntegration(() => {
1419
return {
1520
name: 'OpenFeature',
1621

22+
setup(client: Client) {
23+
client.on('spanEnd', (span: Span) => {
24+
freezeSpanFeatureFlags(span);
25+
});
26+
},
27+
1728
processEvent(event: Event, _hint: EventHint, _client: Client): Event {
1829
return copyFlagsFromScopeToEvent(event);
1930
},
@@ -29,12 +40,14 @@ export class OpenFeatureIntegrationHook implements OpenFeatureHook {
2940
*/
3041
public after(_hookContext: Readonly<HookContext<JsonValue>>, evaluationDetails: EvaluationDetails<JsonValue>): void {
3142
insertFlagToScope(evaluationDetails.flagKey, evaluationDetails.value);
43+
bufferSpanFeatureFlag(evaluationDetails.flagKey, evaluationDetails.value);
3244
}
3345

3446
/**
3547
* On error evaluation result.
3648
*/
3749
public error(hookContext: Readonly<HookContext<JsonValue>>, _error: unknown, _hookHints?: HookHints): void {
3850
insertFlagToScope(hookContext.flagKey, hookContext.defaultValue);
51+
bufferSpanFeatureFlag(hookContext.flagKey, hookContext.defaultValue);
3952
}
4053
}

packages/browser/src/integrations/featureFlags/statsig/integration.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core';
1+
import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core';
22
import { defineIntegration } from '@sentry/core';
3-
import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags';
3+
import {
4+
bufferSpanFeatureFlag,
5+
copyFlagsFromScopeToEvent,
6+
freezeSpanFeatureFlags,
7+
insertFlagToScope,
8+
} from '../../../utils/featureFlags';
49
import type { FeatureGate, StatsigClient } from './types';
510

611
/**
@@ -31,15 +36,20 @@ export const statsigIntegration = defineIntegration(
3136
return {
3237
name: 'Statsig',
3338

34-
processEvent(event: Event, _hint: EventHint, _client: Client): Event {
35-
return copyFlagsFromScopeToEvent(event);
36-
},
39+
setup(client: Client) {
40+
client.on('spanEnd', (span: Span) => {
41+
freezeSpanFeatureFlags(span);
42+
});
3743

38-
setup() {
3944
statsigClient.on('gate_evaluation', (event: { gate: FeatureGate }) => {
4045
insertFlagToScope(event.gate.name, event.gate.value);
46+
bufferSpanFeatureFlag(event.gate.name, event.gate.value);
4147
});
4248
},
49+
50+
processEvent(event: Event, _hint: EventHint, _client: Client): Event {
51+
return copyFlagsFromScopeToEvent(event);
52+
},
4353
};
4454
},
4555
) satisfies IntegrationFn;

packages/browser/src/integrations/featureFlags/unleash/integration.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
import type { Client, Event, EventHint, IntegrationFn } from '@sentry/core';
1+
import type { Client, Event, EventHint, IntegrationFn, Span } from '@sentry/core';
22
import { defineIntegration, fill, logger } from '@sentry/core';
33
import { DEBUG_BUILD } from '../../../debug-build';
4-
import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags';
4+
import {
5+
bufferSpanFeatureFlag,
6+
copyFlagsFromScopeToEvent,
7+
freezeSpanFeatureFlags,
8+
insertFlagToScope,
9+
} from '../../../utils/featureFlags';
510
import type { UnleashClient, UnleashClientClass } from './types';
611

712
type UnleashIntegrationOptions = {
@@ -35,14 +40,20 @@ export const unleashIntegration = defineIntegration(
3540
return {
3641
name: 'Unleash',
3742

38-
processEvent(event: Event, _hint: EventHint, _client: Client): Event {
39-
return copyFlagsFromScopeToEvent(event);
43+
setup(client: Client) {
44+
client.on('spanEnd', (span: Span) => {
45+
freezeSpanFeatureFlags(span);
46+
});
4047
},
4148

4249
setupOnce() {
4350
const unleashClientPrototype = unleashClientClass.prototype as UnleashClient;
4451
fill(unleashClientPrototype, 'isEnabled', _wrappedIsEnabled);
4552
},
53+
54+
processEvent(event: Event, _hint: EventHint, _client: Client): Event {
55+
return copyFlagsFromScopeToEvent(event);
56+
},
4657
};
4758
},
4859
) satisfies IntegrationFn;
@@ -65,6 +76,7 @@ function _wrappedIsEnabled(
6576

6677
if (typeof toggleName === 'string' && typeof result === 'boolean') {
6778
insertFlagToScope(toggleName, result);
79+
bufferSpanFeatureFlag(toggleName, result);
6880
} else if (DEBUG_BUILD) {
6981
logger.error(
7082
`[Feature Flags] UnleashClient.isEnabled does not match expected signature. arg0: ${toggleName} (${typeof toggleName}), result: ${result} (${typeof result})`,

packages/browser/src/utils/featureFlags.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { Event, FeatureFlag } from '@sentry/core';
2-
import { getCurrentScope, logger } from '@sentry/core';
1+
import type { Event, FeatureFlag, Span } from '@sentry/core';
2+
import { getActiveSpan, getCurrentScope, GLOBAL_OBJ, logger } from '@sentry/core';
33
import { DEBUG_BUILD } from '../debug-build';
44

55
/**
@@ -13,6 +13,13 @@ import { DEBUG_BUILD } from '../debug-build';
1313
*/
1414
export const FLAG_BUFFER_SIZE = 100;
1515

16+
/**
17+
* Max number of flag evaluations to record per span.
18+
*/
19+
export const MAX_FLAGS_PER_SPAN = 10;
20+
21+
GLOBAL_OBJ._spanToFlagBufferMap = new WeakMap<Span, FeatureFlag[]>();
22+
1623
/**
1724
* Copies feature flags that are in current scope context to the event context
1825
*/
@@ -87,3 +94,43 @@ export function insertToFlagBuffer(flags: FeatureFlag[], name: string, value: un
8794
result: value,
8895
});
8996
}
97+
98+
/**
99+
* Records a feature flag evaluation for the active span, adding it to a weak map of flag buffers. This is a no-op for non-boolean values.
100+
* The keys in each buffer are unique. Once the buffer for a span reaches maxFlagsPerSpan, subsequent flags are dropped.
101+
*
102+
* @param name Name of the feature flag.
103+
* @param value Value of the feature flag. Non-boolean values are ignored.
104+
* @param maxFlagsPerSpan Max number of flags a buffer should store. Default value should always be used in production.
105+
*/
106+
export function bufferSpanFeatureFlag(
107+
name: string,
108+
value: unknown,
109+
maxFlagsPerSpan: number = MAX_FLAGS_PER_SPAN,
110+
): void {
111+
const spanFlagMap = GLOBAL_OBJ._spanToFlagBufferMap;
112+
if (!spanFlagMap || typeof value !== 'boolean') {
113+
return;
114+
}
115+
116+
const span = getActiveSpan();
117+
if (span) {
118+
const flags = spanFlagMap.get(span) || [];
119+
if (!flags.find(flag => flag.flag === name) && flags.length < maxFlagsPerSpan) {
120+
flags.push({ flag: name, result: value });
121+
}
122+
spanFlagMap.set(span, flags);
123+
}
124+
}
125+
126+
/**
127+
* Add the buffered feature flags for a span to the span attributes. Call this on span end.
128+
*
129+
* @param span Span to add flags to.
130+
*/
131+
export function freezeSpanFeatureFlags(span: Span): void {
132+
const flags = GLOBAL_OBJ._spanToFlagBufferMap?.get(span);
133+
if (flags) {
134+
span.setAttributes(Object.fromEntries(flags.map(flag => [`flag.evaluation.${flag.flag}`, flag.result])));
135+
}
136+
}

packages/core/src/utils-hoist/worldwide.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414

1515
import type { Carrier } from '../carrier';
1616
import type { Client } from '../client';
17+
import type { FeatureFlag } from '../featureFlags';
1718
import type { SerializedLog } from '../types-hoist/log';
19+
import type { Span } from '../types-hoist/span';
1820
import type { SdkSource } from './env';
1921

2022
/** Internal global with common properties and Sentry extensions */
@@ -56,6 +58,10 @@ export type InternalGlobal = {
5658
*/
5759
_sentryModuleMetadata?: Record<string, any>;
5860
_sentryEsmLoaderHookRegistered?: boolean;
61+
/**
62+
* A map of spans to feature flag buffers. Populated by feature flag integrations.
63+
*/
64+
_spanToFlagBufferMap?: WeakMap<Span, FeatureFlag[]>;
5965
} & Carrier;
6066

6167
/** Get's the global object for the current JavaScript runtime */

0 commit comments

Comments
 (0)