Skip to content

Commit e75bcb8

Browse files
authored
feat(core): Ensure normalizedRequest on sdkProcessingMetadata is merged (#14315)
We keep `normalizedRequest` with data like headers, urls etc. on the `sdkProcessingMetadata` on the scope. While this generally works, there are some _potential_ pitfalls/footguns there: One, if you put some (e.g. partial) `normalizedRequest` on the _current_ scope, it will just overwrite the full `normalizedRequest` from the _isolation_ scope, where generally we'll try to put the request data automatically (e.g. in the http instrumentation). Think this example: ```js Sentry.withScope(scope => { scope.setSdkProcessingMetadata({ normalizedRequest: { headers: moreSpecificHeaders } }); }); ``` the resulting event inside of this `withScope` callback would _only_ have the headers, but would miss e.g. url etc. data set. This PR changes this so that the `normalizedRequest` is merged between the types of scopes, so only set fields on e.g. the current scope will overwrite the same fields on the isolation scope, instead of just overwriting the whole normalizedRequest that results. Note that bundle size for browser is sadly anyhow affected (no matter if we go with a/b/c), as the code to merge it between scopes is always shared :(
1 parent e99dd09 commit e75bcb8

File tree

9 files changed

+177
-28
lines changed

9 files changed

+177
-28
lines changed

.size-limit.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ module.exports = [
107107
path: 'packages/browser/build/npm/esm/index.js',
108108
import: createImport('init', 'feedbackAsyncIntegration'),
109109
gzip: true,
110-
limit: '33 KB',
110+
limit: '34 KB',
111111
},
112112
// React SDK (ESM)
113113
{
@@ -160,7 +160,7 @@ module.exports = [
160160
name: 'CDN Bundle (incl. Tracing)',
161161
path: createCDNPath('bundle.tracing.min.js'),
162162
gzip: true,
163-
limit: '38 KB',
163+
limit: '39 KB',
164164
},
165165
{
166166
name: 'CDN Bundle (incl. Tracing, Replay)',

packages/core/src/scope.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
import { dateTimestampInSeconds, generatePropagationContext, isPlainObject, logger, uuid4 } from '@sentry/utils';
2525

2626
import { updateSession } from './session';
27+
import { merge } from './utils/merge';
2728
import { _getSpanForScope, _setSpanForScope } from './utils/spanOnScope';
2829

2930
/**
@@ -479,8 +480,7 @@ class ScopeClass implements ScopeInterface {
479480
* @inheritDoc
480481
*/
481482
public setSDKProcessingMetadata(newData: { [key: string]: unknown }): this {
482-
this._sdkProcessingMetadata = { ...this._sdkProcessingMetadata, ...newData };
483-
483+
this._sdkProcessingMetadata = merge(this._sdkProcessingMetadata, newData, 2);
484484
return this;
485485
}
486486

packages/core/src/utils/applyScopeDataToEvent.ts

+4-10
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Breadcrumb, Event, ScopeData, Span } from '@sentry/types';
22
import { arrayify, dropUndefinedKeys } from '@sentry/utils';
33
import { getDynamicSamplingContextFromSpan } from '../tracing/dynamicSamplingContext';
4+
import { merge } from './merge';
45
import { getRootSpan, spanToJSON, spanToTraceContext } from './spanUtils';
56

67
/**
@@ -46,7 +47,8 @@ export function mergeScopeData(data: ScopeData, mergeData: ScopeData): void {
4647
mergeAndOverwriteScopeData(data, 'tags', tags);
4748
mergeAndOverwriteScopeData(data, 'user', user);
4849
mergeAndOverwriteScopeData(data, 'contexts', contexts);
49-
mergeAndOverwriteScopeData(data, 'sdkProcessingMetadata', sdkProcessingMetadata);
50+
51+
data.sdkProcessingMetadata = merge(data.sdkProcessingMetadata, sdkProcessingMetadata, 2);
5052

5153
if (level) {
5254
data.level = level;
@@ -87,15 +89,7 @@ export function mergeAndOverwriteScopeData<
8789
Prop extends 'extra' | 'tags' | 'user' | 'contexts' | 'sdkProcessingMetadata',
8890
Data extends ScopeData,
8991
>(data: Data, prop: Prop, mergeVal: Data[Prop]): void {
90-
if (mergeVal && Object.keys(mergeVal).length) {
91-
// Clone object
92-
data[prop] = { ...data[prop] };
93-
for (const key in mergeVal) {
94-
if (Object.prototype.hasOwnProperty.call(mergeVal, key)) {
95-
data[prop][key] = mergeVal[key];
96-
}
97-
}
98-
}
92+
data[prop] = merge(data[prop], mergeVal, 1);
9993
}
10094

10195
/** Exported only for tests */

packages/core/src/utils/merge.ts

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Shallow merge two objects.
3+
* Does not mutate the passed in objects.
4+
* Undefined/empty values in the merge object will overwrite existing values.
5+
*
6+
* By default, this merges 2 levels deep.
7+
*/
8+
export function merge<T>(initialObj: T, mergeObj: T, levels = 2): T {
9+
// If the merge value is not an object, or we have no merge levels left,
10+
// we just set the value to the merge value
11+
if (!mergeObj || typeof mergeObj !== 'object' || levels <= 0) {
12+
return mergeObj;
13+
}
14+
15+
// If the merge object is an empty object, and the initial object is not undefined, we return the initial object
16+
if (initialObj && mergeObj && Object.keys(mergeObj).length === 0) {
17+
return initialObj;
18+
}
19+
20+
// Clone object
21+
const output = { ...initialObj };
22+
23+
// Merge values into output, resursively
24+
for (const key in mergeObj) {
25+
if (Object.prototype.hasOwnProperty.call(mergeObj, key)) {
26+
output[key] = merge(output[key], mergeObj[key], levels - 1);
27+
}
28+
}
29+
30+
return output;
31+
}

packages/core/test/lib/scope.test.ts

+21-4
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,27 @@ describe('Scope', () => {
204204
expect(scope['_user']).toEqual({});
205205
});
206206

207-
test('setProcessingMetadata', () => {
208-
const scope = new Scope();
209-
scope.setSDKProcessingMetadata({ dogs: 'are great!' });
210-
expect(scope['_sdkProcessingMetadata'].dogs).toEqual('are great!');
207+
describe('setProcessingMetadata', () => {
208+
test('it works with no initial data', () => {
209+
const scope = new Scope();
210+
scope.setSDKProcessingMetadata({ dogs: 'are great!' });
211+
expect(scope['_sdkProcessingMetadata'].dogs).toEqual('are great!');
212+
});
213+
214+
test('it overwrites data', () => {
215+
const scope = new Scope();
216+
scope.setSDKProcessingMetadata({ dogs: 'are great!' });
217+
scope.setSDKProcessingMetadata({ dogs: 'are really great!' });
218+
scope.setSDKProcessingMetadata({ cats: 'are also great!' });
219+
scope.setSDKProcessingMetadata({ obj: { nested1: 'value1', nested: 'value1' } });
220+
scope.setSDKProcessingMetadata({ obj: { nested2: 'value2', nested: 'value2' } });
221+
222+
expect(scope['_sdkProcessingMetadata']).toEqual({
223+
dogs: 'are really great!',
224+
cats: 'are also great!',
225+
obj: { nested2: 'value2', nested: 'value2', nested1: 'value1' },
226+
});
227+
});
211228
});
212229

213230
test('set and get propagation context', () => {

packages/core/test/lib/utils/applyScopeDataToEvent.test.ts

+29-3
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,15 @@ describe('mergeScopeData', () => {
134134
contexts: { os: { name: 'os1' }, culture: { display_name: 'name1' } },
135135
attachments: [attachment1],
136136
propagationContext: { spanId: '1', traceId: '1' },
137-
sdkProcessingMetadata: { aa: 'aa', bb: 'aa' },
137+
sdkProcessingMetadata: {
138+
aa: 'aa',
139+
bb: 'aa',
140+
obj: { key: 'value' },
141+
normalizedRequest: {
142+
url: 'oldUrl',
143+
method: 'oldMethod',
144+
},
145+
},
138146
fingerprint: ['aa', 'bb'],
139147
};
140148
const data2: ScopeData = {
@@ -146,7 +154,15 @@ describe('mergeScopeData', () => {
146154
contexts: { os: { name: 'os2' } },
147155
attachments: [attachment2, attachment3],
148156
propagationContext: { spanId: '2', traceId: '2' },
149-
sdkProcessingMetadata: { bb: 'bb', cc: 'bb' },
157+
sdkProcessingMetadata: {
158+
bb: 'bb',
159+
cc: 'bb',
160+
obj: { key2: 'value2' },
161+
normalizedRequest: {
162+
url: 'newUrl',
163+
headers: {},
164+
},
165+
},
150166
fingerprint: ['cc'],
151167
};
152168
mergeScopeData(data1, data2);
@@ -159,7 +175,17 @@ describe('mergeScopeData', () => {
159175
contexts: { os: { name: 'os2' }, culture: { display_name: 'name1' } },
160176
attachments: [attachment1, attachment2, attachment3],
161177
propagationContext: { spanId: '2', traceId: '2' },
162-
sdkProcessingMetadata: { aa: 'aa', bb: 'bb', cc: 'bb' },
178+
sdkProcessingMetadata: {
179+
aa: 'aa',
180+
bb: 'bb',
181+
cc: 'bb',
182+
obj: { key: 'value', key2: 'value2' },
183+
normalizedRequest: {
184+
url: 'newUrl',
185+
method: 'oldMethod',
186+
headers: {},
187+
},
188+
},
163189
fingerprint: ['aa', 'bb', 'cc'],
164190
});
165191
});
+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { merge } from '../../../src/utils/merge';
2+
3+
describe('merge', () => {
4+
it('works with empty objects', () => {
5+
const oldData = {};
6+
const newData = {};
7+
8+
const actual = merge(oldData, newData);
9+
10+
expect(actual).toEqual({});
11+
expect(actual).toBe(oldData);
12+
expect(actual).not.toBe(newData);
13+
});
14+
15+
it('works with empty merge object', () => {
16+
const oldData = { aa: 'aha' };
17+
const newData = {};
18+
19+
const actual = merge(oldData, newData);
20+
21+
expect(actual).toEqual({ aa: 'aha' });
22+
expect(actual).toBe(oldData);
23+
expect(actual).not.toBe(newData);
24+
});
25+
26+
it('works with arbitrary data', () => {
27+
const oldData = {
28+
old1: 'old1',
29+
old2: 'old2',
30+
obj: { key: 'value1', key1: 'value1', deep: { key: 'value' } },
31+
} as any;
32+
const newData = {
33+
new1: 'new1',
34+
old2: 'new2',
35+
obj: { key2: 'value2', key: 'value2', deep: { key2: 'value2' } },
36+
} as any;
37+
38+
const actual = merge(oldData, newData);
39+
40+
expect(actual).toEqual({
41+
old1: 'old1',
42+
old2: 'new2',
43+
new1: 'new1',
44+
obj: {
45+
key2: 'value2',
46+
key: 'value2',
47+
key1: 'value1',
48+
deep: { key2: 'value2' },
49+
},
50+
});
51+
expect(actual).not.toBe(oldData);
52+
expect(actual).not.toBe(newData);
53+
});
54+
55+
it.each([
56+
[undefined, { a: 'aa' }, { a: 'aa' }],
57+
[{ a: 'aa' }, undefined, undefined],
58+
[{ a: 'aa' }, null, null],
59+
[{ a: 'aa' }, { a: undefined }, { a: undefined }],
60+
[{ a: 'aa' }, { a: null }, { a: null }],
61+
[{ a: 'aa' }, { a: '' }, { a: '' }],
62+
[
63+
{ a0: { a1: { a2: { a3: { a4: 'a4a' }, a3a: 'a3a' }, a2a: 'a2a' }, a1a: 'a1a' }, a0a: 'a0a' },
64+
{ a0: { a1: { a2: { a3: { a4: 'a4b' }, a3b: 'a3b' }, a2b: 'a2b' }, a1b: 'a1b' }, a0b: 'a0b' },
65+
{
66+
a0: { a1: { a2: { a3: { a4: 'a4b' }, a3b: 'a3b' }, a2b: 'a2b' }, a1b: 'a1b', a1a: 'a1a' },
67+
a0b: 'a0b',
68+
a0a: 'a0a',
69+
},
70+
],
71+
])('works with %p and %p', (oldData, newData, expected) => {
72+
const actual = merge(oldData, newData as any);
73+
expect(actual).toEqual(expected);
74+
});
75+
});

packages/node/src/integrations/http/SentryHttpInstrumentation.ts

+10-6
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
66
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
77
import { getRequestInfo } from '@opentelemetry/instrumentation-http';
88
import { addBreadcrumb, getClient, getIsolationScope, withIsolationScope } from '@sentry/core';
9-
import type { PolymorphicRequest, RequestEventData, SanitizedRequestData } from '@sentry/types';
9+
import type { PolymorphicRequest, RequestEventData, SanitizedRequestData, Scope } from '@sentry/types';
1010
import {
1111
getBreadcrumbLogLevelFromHttpStatusCode,
1212
getSanitizedUrlString,
@@ -150,10 +150,14 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
150150
cookies,
151151
};
152152

153-
patchRequestToCaptureBody(request, normalizedRequest);
153+
patchRequestToCaptureBody(request, isolationScope);
154154

155155
// Update the isolation scope, isolate this request
156-
isolationScope.setSDKProcessingMetadata({ request, normalizedRequest });
156+
// TODO(v9): Stop setting `request`, we only rely on normalizedRequest anymore
157+
isolationScope.setSDKProcessingMetadata({
158+
request,
159+
normalizedRequest,
160+
});
157161

158162
const client = getClient<NodeClient>();
159163
if (client && client.getOptions().autoSessionTracking) {
@@ -347,7 +351,7 @@ function getBreadcrumbData(request: http.ClientRequest): Partial<SanitizedReques
347351
* we monkey patch `req.on('data')` to intercept the body chunks.
348352
* This way, we only read the body if the user also consumes the body, ensuring we do not change any behavior in unexpected ways.
349353
*/
350-
function patchRequestToCaptureBody(req: IncomingMessage, normalizedRequest: RequestEventData): void {
354+
function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope): void {
351355
const chunks: Buffer[] = [];
352356

353357
function getChunksSize(): number {
@@ -396,9 +400,9 @@ function patchRequestToCaptureBody(req: IncomingMessage, normalizedRequest: Requ
396400
try {
397401
const body = Buffer.concat(chunks).toString('utf-8');
398402

399-
// We mutate the passed in normalizedRequest and add the body to it
400403
if (body) {
401-
normalizedRequest.data = body;
404+
const normalizedRequest = { data: body } satisfies RequestEventData;
405+
isolationScope.setSDKProcessingMetadata({ normalizedRequest });
402406
}
403407
} catch {
404408
// ignore errors here

packages/types/src/scope.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,9 @@ export interface Scope {
217217
clearAttachments(): this;
218218

219219
/**
220-
* Add data which will be accessible during event processing but won't get sent to Sentry
220+
* Add data which will be accessible during event processing but won't get sent to Sentry.
221+
*
222+
* TODO(v9): We should type this stricter, so that e.g. `normalizedRequest` is strictly typed.
221223
*/
222224
setSDKProcessingMetadata(newData: { [key: string]: unknown }): this;
223225

0 commit comments

Comments
 (0)