Skip to content

Commit dd849ee

Browse files
committed
Merge branch 'develop' into abhi-parameterize-logs
2 parents ab49f97 + 9ca030d commit dd849ee

File tree

8 files changed

+195
-16
lines changed

8 files changed

+195
-16
lines changed

packages/browser-utils/src/instrument/xhr.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@ export function instrumentXHR(): void {
3030

3131
// eslint-disable-next-line @typescript-eslint/unbound-method
3232
xhrproto.open = new Proxy(xhrproto.open, {
33-
apply(originalOpen, xhrOpenThisArg: XMLHttpRequest & SentryWrappedXMLHttpRequest, xhrOpenArgArray) {
33+
apply(
34+
originalOpen,
35+
xhrOpenThisArg: XMLHttpRequest & SentryWrappedXMLHttpRequest,
36+
xhrOpenArgArray:
37+
| [method: string, url: string | URL]
38+
| [method: string, url: string | URL, async: boolean, username?: string | null, password?: string | null],
39+
) {
3440
// NOTE: If you are a Sentry user, and you are seeing this stack frame,
3541
// it means the error, that was caused by your XHR call did not
3642
// have a stack trace. If you are using HttpClient integration,
@@ -43,7 +49,7 @@ export function instrumentXHR(): void {
4349
// open() should always be called with two or more arguments
4450
// But to be on the safe side, we actually validate this and bail out if we don't have a method & url
4551
const method = isString(xhrOpenArgArray[0]) ? xhrOpenArgArray[0].toUpperCase() : undefined;
46-
const url = parseUrl(xhrOpenArgArray[1]);
52+
const url = parseXhrUrlArg(xhrOpenArgArray[1]);
4753

4854
if (!method || !url) {
4955
return originalOpen.apply(xhrOpenThisArg, xhrOpenArgArray);
@@ -147,16 +153,23 @@ export function instrumentXHR(): void {
147153
});
148154
}
149155

150-
function parseUrl(url: string | unknown): string | undefined {
156+
/**
157+
* Parses the URL argument of a XHR method to a string.
158+
*
159+
* See: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/open#url
160+
* url: A string or any other object with a stringifier — including a URL object — that provides the URL of the resource to send the request to.
161+
*
162+
* @param url - The URL argument of an XHR method
163+
* @returns The parsed URL string or undefined if the URL is invalid
164+
*/
165+
function parseXhrUrlArg(url: unknown): string | undefined {
151166
if (isString(url)) {
152167
return url;
153168
}
154169

155170
try {
156-
// url can be a string or URL
157-
// but since URL is not available in IE11, we do not check for it,
158-
// but simply assume it is an URL and return `toString()` from it (which returns the full URL)
159-
// If that fails, we just return undefined
171+
// If the passed in argument is not a string, it should have a `toString` method as a stringifier.
172+
// If that fails, we just return undefined (like in IE11 where URL is not available)
160173
return (url as URL).toString();
161174
} catch {} // eslint-disable-line no-empty
162175

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { getClient } from '@sentry/core';
2+
3+
/**
4+
* A function to diagnose why the SDK might not be successfully sending data.
5+
*
6+
* Possible return values wrapped in a Promise:
7+
* - `"no-client-active"` - There was no active client when the function was called. This possibly means that the SDK was not initialized yet.
8+
* - `"sentry-unreachable"` - The Sentry SaaS servers were not reachable. This likely means that there is an ad blocker active on the page or that there are other connection issues.
9+
*
10+
* If the function doesn't detect an issue it resolves to `undefined`.
11+
*/
12+
export async function diagnoseSdkConnectivity(): Promise<
13+
'no-client-active' | 'sentry-unreachable' | 'no-dsn-configured' | void
14+
> {
15+
const client = getClient();
16+
17+
if (!client) {
18+
return 'no-client-active';
19+
}
20+
21+
if (!client.getDsn()) {
22+
return 'no-dsn-configured';
23+
}
24+
25+
try {
26+
// If fetch throws, there is likely an ad blocker active or there are other connective issues.
27+
await fetch(
28+
// We want this to be as close as possible to an actual ingest URL so that ad blockers will actually block the request
29+
// We are using the "sentry-sdks" org with id 447951 not to pollute any actual organizations.
30+
'https://o447951.ingest.sentry.io/api/1337/envelope/?sentry_version=7&sentry_key=1337&sentry_client=sentry.javascript.browser%2F1.33.7',
31+
{
32+
body: '{}',
33+
method: 'POST',
34+
mode: 'cors',
35+
credentials: 'omit',
36+
},
37+
);
38+
} catch {
39+
return 'sentry-unreachable';
40+
}
41+
}

packages/browser/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,4 @@ export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './int
7171
export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature';
7272
export { unleashIntegration } from './integrations/featureFlags/unleash';
7373
export { statsigIntegration } from './integrations/featureFlags/statsig';
74+
export { diagnoseSdkConnectivity } from './diagnose-sdk';

packages/core/src/client.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -623,12 +623,19 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
623623
public on(hook: 'close', callback: () => void): () => void;
624624

625625
/**
626-
* A hook that is called before a log is captured
626+
* A hook that is called before a log is captured. This hooks runs before `beforeSendLog` is fired.
627627
*
628628
* @returns {() => void} A function that, when executed, removes the registered callback.
629629
*/
630630
public on(hook: 'beforeCaptureLog', callback: (log: Log) => void): () => void;
631631

632+
/**
633+
* A hook that is called after a log is captured
634+
*
635+
* @returns {() => void} A function that, when executed, removes the registered callback.
636+
*/
637+
public on(hook: 'afterCaptureLog', callback: (log: Log) => void): () => void;
638+
632639
/**
633640
* Register a hook on this client.
634641
*/
@@ -777,10 +784,15 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
777784
public emit(hook: 'close'): void;
778785

779786
/**
780-
* Emit a hook event for client before capturing a log
787+
* Emit a hook event for client before capturing a log. This hooks runs before `beforeSendLog` is fired.
781788
*/
782789
public emit(hook: 'beforeCaptureLog', log: Log): void;
783790

791+
/**
792+
* Emit a hook event for client after capturing a log.
793+
*/
794+
public emit(hook: 'afterCaptureLog', log: Log): void;
795+
784796
/**
785797
* Emit a hook that was previously registered via `on()`.
786798
*/

packages/core/src/logs/index.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,18 +62,28 @@ export function logAttributeToSerializedLogAttribute(key: string, value: unknown
6262
* @experimental This method will experience breaking changes. This is not yet part of
6363
* the stable Sentry SDK API and can be changed or removed without warning.
6464
*/
65-
export function _INTERNAL_captureLog(log: Log, client = getClient(), scope = getCurrentScope()): void {
65+
export function _INTERNAL_captureLog(beforeLog: Log, client = getClient(), scope = getCurrentScope()): void {
6666
if (!client) {
6767
DEBUG_BUILD && logger.warn('No client available to capture log.');
6868
return;
6969
}
7070

7171
const { _experiments, release, environment } = client.getOptions();
72-
if (!_experiments?.enableLogs) {
72+
const { enableLogs = false, beforeSendLog } = _experiments ?? {};
73+
if (!enableLogs) {
7374
DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.');
7475
return;
7576
}
7677

78+
client.emit('beforeCaptureLog', beforeLog);
79+
80+
const log = beforeSendLog ? beforeSendLog(beforeLog) : beforeLog;
81+
if (!log) {
82+
client.recordDroppedEvent('before_send', 'log_item', 1);
83+
DEBUG_BUILD && logger.warn('beforeSendLog returned null, log will not be captured.');
84+
return;
85+
}
86+
7787
const [, traceContext] = _getTraceInfoFromScope(client, scope);
7888

7989
const { level, message, attributes, severityNumber } = log;
@@ -125,7 +135,7 @@ export function _INTERNAL_captureLog(log: Log, client = getClient(), scope = get
125135
}
126136
}
127137

128-
client.emit('beforeCaptureLog', log);
138+
client.emit('afterCaptureLog', log);
129139
}
130140

131141
/**

packages/core/src/server-runtime-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export class ServerRuntimeClient<
5757
_INTERNAL_flushLogsBuffer(client);
5858
});
5959

60-
this.on('beforeCaptureLog', log => {
60+
this.on('afterCaptureLog', log => {
6161
client._logWeight += estimateLogSizeInBytes(log);
6262

6363
// We flush the logs buffer if it exceeds 0.8 MB

packages/core/src/types-hoist/options.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { CaptureContext } from '../scope';
22
import type { Breadcrumb, BreadcrumbHint } from './breadcrumb';
33
import type { ErrorEvent, EventHint, TransactionEvent } from './event';
44
import type { Integration } from './integration';
5+
import type { Log } from './log';
56
import type { TracesSamplerSamplingContext } from './samplingcontext';
67
import type { SdkMetadata } from './sdkmetadata';
78
import type { SpanJSON } from './span';
@@ -188,6 +189,17 @@ export interface ClientOptions<TO extends BaseTransportOptions = BaseTransportOp
188189
* If logs support should be enabled. Defaults to false.
189190
*/
190191
enableLogs?: boolean;
192+
/**
193+
* An event-processing callback for logs, guaranteed to be invoked after all other log
194+
* processors. This allows a log to be modified or dropped before it's sent.
195+
*
196+
* Note that you must return a valid log from this callback. If you do not wish to modify the log, simply return
197+
* it at the end. Returning `null` will cause the log to be dropped.
198+
*
199+
* @param log The log generated by the SDK.
200+
* @returns A new log that will be sent | null.
201+
*/
202+
beforeSendLog?: (log: Log) => Log | null;
191203
};
192204

193205
/**

packages/core/test/lib/logs/index.test.ts

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import {
77
} from '../../../src/logs';
88
import { TestClient, getDefaultTestClientOptions } from '../../mocks/client';
99
import * as loggerModule from '../../../src/utils-hoist/logger';
10-
import { Scope } from '../../../src';
11-
import { parameterize } from '../../../src/utils/parameterize';
10+
import { Scope, fmt } from '../../../src';
11+
import type { Log } from '../../../src/types-hoist/log';
1212

1313
const PUBLIC_DSN = 'https://username@domain/123';
1414

@@ -194,7 +194,7 @@ describe('_INTERNAL_captureLog', () => {
194194
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } });
195195
const client = new TestClient(options);
196196

197-
const parameterizedMessage = parameterize`Hello ${'John'}, welcome to ${'Sentry'}`;
197+
const parameterizedMessage = fmt`Hello ${'John'}, welcome to ${'Sentry'}`;
198198

199199
_INTERNAL_captureLog({ level: 'info', message: parameterizedMessage }, client, undefined);
200200

@@ -216,4 +216,94 @@ describe('_INTERNAL_captureLog', () => {
216216
]),
217217
);
218218
});
219+
220+
it('processes logs through beforeSendLog when provided', () => {
221+
const beforeSendLog = vi.fn().mockImplementation(log => ({
222+
...log,
223+
message: `Modified: ${log.message}`,
224+
attributes: { ...log.attributes, processed: true },
225+
}));
226+
227+
const options = getDefaultTestClientOptions({
228+
dsn: PUBLIC_DSN,
229+
_experiments: { enableLogs: true, beforeSendLog },
230+
});
231+
const client = new TestClient(options);
232+
233+
_INTERNAL_captureLog(
234+
{
235+
level: 'info',
236+
message: 'original message',
237+
attributes: { original: true },
238+
},
239+
client,
240+
undefined,
241+
);
242+
243+
expect(beforeSendLog).toHaveBeenCalledWith({
244+
level: 'info',
245+
message: 'original message',
246+
attributes: { original: true },
247+
});
248+
249+
const logBuffer = _INTERNAL_getLogBuffer(client);
250+
expect(logBuffer).toBeDefined();
251+
expect(logBuffer?.[0]).toEqual(
252+
expect.objectContaining({
253+
body: {
254+
stringValue: 'Modified: original message',
255+
},
256+
attributes: expect.arrayContaining([
257+
expect.objectContaining({ key: 'processed', value: { boolValue: true } }),
258+
expect.objectContaining({ key: 'original', value: { boolValue: true } }),
259+
]),
260+
}),
261+
);
262+
});
263+
264+
it('drops logs when beforeSendLog returns null', () => {
265+
const beforeSendLog = vi.fn().mockReturnValue(null);
266+
const recordDroppedEventSpy = vi.spyOn(TestClient.prototype, 'recordDroppedEvent');
267+
const loggerWarnSpy = vi.spyOn(loggerModule.logger, 'warn').mockImplementation(() => undefined);
268+
269+
const options = getDefaultTestClientOptions({
270+
dsn: PUBLIC_DSN,
271+
_experiments: { enableLogs: true, beforeSendLog },
272+
});
273+
const client = new TestClient(options);
274+
275+
_INTERNAL_captureLog(
276+
{
277+
level: 'info',
278+
message: 'test message',
279+
},
280+
client,
281+
undefined,
282+
);
283+
284+
expect(beforeSendLog).toHaveBeenCalled();
285+
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'log_item', 1);
286+
expect(loggerWarnSpy).toHaveBeenCalledWith('beforeSendLog returned null, log will not be captured.');
287+
expect(_INTERNAL_getLogBuffer(client)).toBeUndefined();
288+
289+
recordDroppedEventSpy.mockRestore();
290+
loggerWarnSpy.mockRestore();
291+
});
292+
293+
it('emits beforeCaptureLog and afterCaptureLog events', () => {
294+
const beforeCaptureLogSpy = vi.spyOn(TestClient.prototype, 'emit');
295+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } });
296+
const client = new TestClient(options);
297+
298+
const log: Log = {
299+
level: 'info',
300+
message: 'test message',
301+
};
302+
303+
_INTERNAL_captureLog(log, client, undefined);
304+
305+
expect(beforeCaptureLogSpy).toHaveBeenCalledWith('beforeCaptureLog', log);
306+
expect(beforeCaptureLogSpy).toHaveBeenCalledWith('afterCaptureLog', log);
307+
beforeCaptureLogSpy.mockRestore();
308+
});
219309
});

0 commit comments

Comments
 (0)