Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(browser): Add graphqlClientIntegration #13783

Open
wants to merge 62 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 54 commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
b88b4f9
feat(browser): Add `graphqlClientIntegration`
Zen-cronic Sep 25, 2024
17e89b1
feat(browser): Add support for fetch graphql request
Zen-cronic Sep 26, 2024
be96da7
test(browser): Remove skip test
Zen-cronic Sep 26, 2024
b985d6a
fix(browser): Attach request payload to fetch instrumentation only fo…
Zen-cronic Sep 27, 2024
f7a021b
fix(browser): Emit the `outgoingRequestSpanStart` hook after the span…
Zen-cronic Sep 27, 2024
285e619
feat(browser): Update breadcrumbs with graphql request data
Zen-cronic Sep 29, 2024
0095a88
fix(browser): Change breadcrumb hook signature to not be graphql-spec…
Zen-cronic Jan 7, 2025
47618d8
fix(browser): Change span hook signature to not be graphql-specific
Zen-cronic Jan 7, 2025
581374b
fix(browser): Add more requested fixes
Zen-cronic Jan 7, 2025
6ef6953
fix(browser): Refactor to reduce type assertions
Zen-cronic Jan 7, 2025
1cbe786
chore(browser): Fix lint errors
Zen-cronic Jan 7, 2025
4f64feb
fix(browser): Refactor span handler to use hint
Zen-cronic Jan 19, 2025
7517a93
fix(browser): Refactor breadcrumb handlers to use hint
Zen-cronic Jan 19, 2025
8dcf72b
test(browser): Add tests for `getRequestPayloadXhrOrFetch`
Zen-cronic Jan 19, 2025
7fdec36
refactor(browser): Remove type assertions
Zen-cronic Jan 19, 2025
ae04bda
fix(browser): Remove unnecessary `FetchInput` type
Zen-cronic Jan 19, 2025
630d678
chore(browser): Remove deleted import
Zen-cronic Jan 23, 2025
719d727
fix(browser): Resolve rebase conflicts
Zen-cronic Jan 23, 2025
4c20f14
fix(core): Revert resolved rebase conflict
Zen-cronic Jan 23, 2025
4ab14d3
Merge branch 'getsentry:develop' into feat/graphqlClientIntegration
Zen-cronic Jan 23, 2025
ff49b03
fix: Lint
Zen-cronic Jan 23, 2025
8ea71ba
refactor(browser-utils): Move `getBodyString`
Zen-cronic Jan 23, 2025
f778e13
refactor(core): Move `hasProp`
Zen-cronic Jan 23, 2025
eccfa79
chore: Merge import statements
Zen-cronic Jan 23, 2025
e4581b7
refactor(browser-utils): Move `FetchHint` and `XhrHint`
Zen-cronic Jan 23, 2025
521f61f
chore(e2e): Replace deprecated imports
Zen-cronic Jan 23, 2025
7a75d8a
feat(browser): Add `graphqlClientIntegration`
Zen-cronic Sep 25, 2024
b611943
feat(browser): Add support for fetch graphql request
Zen-cronic Sep 26, 2024
1be1082
test(browser): Remove skip test
Zen-cronic Sep 26, 2024
29c1dc1
fix(browser): Attach request payload to fetch instrumentation only fo…
Zen-cronic Sep 27, 2024
0a5663c
fix(browser): Emit the `outgoingRequestSpanStart` hook after the span…
Zen-cronic Sep 27, 2024
f5320b2
feat(browser): Update breadcrumbs with graphql request data
Zen-cronic Sep 29, 2024
26a7873
fix(browser): Change breadcrumb hook signature to not be graphql-spec…
Zen-cronic Jan 7, 2025
f0c88e8
fix(browser): Change span hook signature to not be graphql-specific
Zen-cronic Jan 7, 2025
4e78e20
fix(browser): Add more requested fixes
Zen-cronic Jan 7, 2025
4cb51b4
fix(browser): Refactor to reduce type assertions
Zen-cronic Jan 7, 2025
95dcbe8
chore(browser): Fix lint errors
Zen-cronic Jan 7, 2025
efbfe1f
fix(browser): Refactor span handler to use hint
Zen-cronic Jan 19, 2025
787b18c
fix(browser): Refactor breadcrumb handlers to use hint
Zen-cronic Jan 19, 2025
93a4853
test(browser): Add tests for `getRequestPayloadXhrOrFetch`
Zen-cronic Jan 19, 2025
859a1ee
refactor(browser): Remove type assertions
Zen-cronic Jan 19, 2025
ad6bc30
fix(browser): Remove unnecessary `FetchInput` type
Zen-cronic Jan 19, 2025
f4c151a
chore(browser): Remove deleted import
Zen-cronic Jan 23, 2025
8d5550e
fix(browser): Resolve rebase conflicts
Zen-cronic Jan 23, 2025
3e3322d
fix(core): Revert resolved rebase conflict
Zen-cronic Jan 23, 2025
8ec59ce
fix: Lint
Zen-cronic Jan 23, 2025
59c8fcd
refactor(browser-utils): Move `getBodyString`
Zen-cronic Jan 23, 2025
ee8b1d7
refactor(core): Move `hasProp`
Zen-cronic Jan 23, 2025
6d0d3c5
chore: Merge import statements
Zen-cronic Jan 23, 2025
db865bf
refactor(browser-utils): Move `FetchHint` and `XhrHint`
Zen-cronic Jan 23, 2025
2e8d2ae
chore(e2e): Replace deprecated imports
Zen-cronic Jan 23, 2025
07af617
Merge branch 'getsentry:develop' into feat/graphqlClientIntegration
Zen-cronic Jan 24, 2025
1405707
Merge remote-tracking branch 'refs/remotes/origin/feat/graphqlClientI…
Zen-cronic Jan 24, 2025
129a774
Merge branch 'getsentry:develop' into feat/graphqlClientIntegration
Zen-cronic Jan 29, 2025
d3ba78f
Merge branch 'getsentry:develop' into feat/graphqlClientIntegration
Zen-cronic Jan 29, 2025
4d06a2f
chore(browser-utils): Remove jest
Zen-cronic Jan 29, 2025
67e2971
fix(browser): Change parsing the fetch req payload to allow more type…
Zen-cronic Jan 29, 2025
200d810
ref(browser-utils): Use undefined assertion
Zen-cronic Jan 29, 2025
514ef47
ref(replay): Extend ReplayLogger from core Logger
Zen-cronic Jan 30, 2025
a3a36e3
ref(browser-utils): Revert `getBodyString` tests to original case
Zen-cronic Jan 30, 2025
22c2c92
fix(core): Make `endTimestamp` of `FetchBreadcrumbHint` optional
Zen-cronic Jan 31, 2025
e7876d7
feat(browser): Add support for queries with operation name
Zen-cronic Jan 31, 2025
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const query = `query Test{
people {
name
pet
}
}`;

const requestBody = JSON.stringify({ query });

fetch('http://sentry-test.io/foo', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: requestBody,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { expect } from '@playwright/test';
import type { Event } from '@sentry/core';

import { sentryTest } from '../../../../utils/fixtures';
import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers';

// Duplicate from subject.js
const query = `query Test{
people {
name
pet
}
}`;
const queryPayload = JSON.stringify({ query });

sentryTest('should update spans for GraphQL Fetch requests', async ({ getLocalTestUrl, page }) => {
const url = await getLocalTestUrl({ testDir: __dirname });

await page.route('**/foo', route => {
return route.fulfill({
status: 200,
body: JSON.stringify({
people: [
{ name: 'Amy', pet: 'dog' },
{ name: 'Jay', pet: 'cat' },
],
}),
headers: {
'Content-Type': 'application/json',
},
});
});

const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
const requestSpans = eventData.spans?.filter(({ op }) => op === 'http.client');

expect(requestSpans).toHaveLength(1);

expect(requestSpans![0]).toMatchObject({
description: 'POST http://sentry-test.io/foo (query Test)',
parent_span_id: eventData.contexts?.trace?.span_id,
span_id: expect.any(String),
start_timestamp: expect.any(Number),
timestamp: expect.any(Number),
trace_id: eventData.contexts?.trace?.trace_id,
status: 'ok',
data: expect.objectContaining({
type: 'fetch',
'http.method': 'POST',
'http.url': 'http://sentry-test.io/foo',
url: 'http://sentry-test.io/foo',
'server.address': 'sentry-test.io',
'sentry.op': 'http.client',
'sentry.origin': 'auto.http.browser',
'graphql.document': queryPayload,
}),
});
});

sentryTest('should update breadcrumbs for GraphQL Fetch requests', async ({ getLocalTestUrl, page }) => {
const url = await getLocalTestUrl({ testDir: __dirname });

await page.route('**/foo', route => {
return route.fulfill({
status: 200,
body: JSON.stringify({
people: [
{ name: 'Amy', pet: 'dog' },
{ name: 'Jay', pet: 'cat' },
],
}),
headers: {
'Content-Type': 'application/json',
},
});
});

const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);

expect(eventData?.breadcrumbs?.length).toBe(1);

expect(eventData!.breadcrumbs![0]).toEqual({
timestamp: expect.any(Number),
category: 'fetch',
type: 'http',
data: {
method: 'POST',
status_code: 200,
url: 'http://sentry-test.io/foo',
__span: expect.any(String),
'graphql.document': query,
'graphql.operation': 'query Test',
},
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://[email protected]/1337',
integrations: [
Sentry.browserTracingIntegration(),
Sentry.graphqlClientIntegration({
endpoints: ['http://sentry-test.io/foo'],
}),
],
tracesSampleRate: 1,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const xhr = new XMLHttpRequest();

xhr.open('POST', 'http://sentry-test.io/foo');
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('Content-Type', 'application/json');

const query = `query Test{
people {
name
pet
}
}`;

const requestBody = JSON.stringify({ query });
xhr.send(requestBody);
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { expect } from '@playwright/test';
import type { Event } from '@sentry/core';

import { sentryTest } from '../../../../utils/fixtures';
import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers';

// Duplicate from subject.js
const query = `query Test{
people {
name
pet
}
}`;
const queryPayload = JSON.stringify({ query });

sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTestUrl, page }) => {
const url = await getLocalTestUrl({ testDir: __dirname });

await page.route('**/foo', route => {
return route.fulfill({
status: 200,
body: JSON.stringify({
people: [
{ name: 'Amy', pet: 'dog' },
{ name: 'Jay', pet: 'cat' },
],
}),
headers: {
'Content-Type': 'application/json',
},
});
});

const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
const requestSpans = eventData.spans?.filter(({ op }) => op === 'http.client');

expect(requestSpans).toHaveLength(1);

expect(requestSpans![0]).toMatchObject({
description: 'POST http://sentry-test.io/foo (query Test)',
parent_span_id: eventData.contexts?.trace?.span_id,
span_id: expect.any(String),
start_timestamp: expect.any(Number),
timestamp: expect.any(Number),
trace_id: eventData.contexts?.trace?.trace_id,
status: 'ok',
data: {
type: 'xhr',
'http.method': 'POST',
'http.url': 'http://sentry-test.io/foo',
url: 'http://sentry-test.io/foo',
'server.address': 'sentry-test.io',
'sentry.op': 'http.client',
'sentry.origin': 'auto.http.browser',
'graphql.document': queryPayload,
},
});
});

sentryTest('should update breadcrumbs for GraphQL XHR requests', async ({ getLocalTestUrl, page }) => {
const url = await getLocalTestUrl({ testDir: __dirname });

await page.route('**/foo', route => {
return route.fulfill({
status: 200,
body: JSON.stringify({
people: [
{ name: 'Amy', pet: 'dog' },
{ name: 'Jay', pet: 'cat' },
],
}),
headers: {
'Content-Type': 'application/json',
},
});
});

const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);

expect(eventData?.breadcrumbs?.length).toBe(1);

expect(eventData!.breadcrumbs![0]).toEqual({
timestamp: expect.any(Number),
category: 'xhr',
type: 'http',
data: {
method: 'POST',
status_code: 200,
url: 'http://sentry-test.io/foo',
'graphql.document': query,
'graphql.operation': 'query Test',
},
});
});
6 changes: 3 additions & 3 deletions packages/browser-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@
"clean": "rimraf build coverage sentry-internal-browser-utils-*.tgz",
"fix": "eslint . --format stylish --fix",
"lint": "eslint . --format stylish",
"test:unit": "jest",
"test": "jest",
"test:watch": "jest --watch",
"test:unit": "vitest run",
"test": "vitest run",
"test:watch": "vitest --watch",
"yalc:publish": "yalc publish --push --sig"
},
"volta": {
Expand Down
4 changes: 4 additions & 0 deletions packages/browser-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ export { addHistoryInstrumentationHandler } from './instrument/history';
export { fetch, setTimeout, clearCachedImplementation, getNativeImplementation } from './getNativeImplementation';

export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrument/xhr';

export { getBodyString } from './networkUtils';

export type { FetchHint, NetworkMetaWarning, XhrHint } from './types';
72 changes: 72 additions & 0 deletions packages/browser-utils/src/networkUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { ConsoleLevel, Logger } from '@sentry/core';
import { DEBUG_BUILD } from './debug-build';
import type { NetworkMetaWarning } from './types';

type ReplayConsoleLevels = Extract<ConsoleLevel, 'info' | 'warn' | 'error' | 'log'>;
type LoggerMethod = (...args: unknown[]) => void;
type LoggerConsoleMethods = Record<ReplayConsoleLevels, LoggerMethod>;

interface LoggerConfig {
captureExceptions: boolean;
traceInternals: boolean;
}

// Duplicate from replay-internal
interface ReplayLogger extends LoggerConsoleMethods {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this, imo, is not the optimal approach - it's only duped here b/c replay-internal needs to use the exception method in getBodyString to set the PREFIX as Replay :

DEBUG_BUILD && logger.exception(error, 'Failed to serialize body', body);

_logger.exception = (error: unknown, ...message: unknown[]) => {
if (message.length && _logger.error) {
_logger.error(...message);
}
coreLogger.error(PREFIX, error);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, let's get rid of this here and just use the regular logger.log() instead! Make the type for the argument logger?: Logger which should also match for replay logger, I suppose, as that extends the core logger?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would go even one step further and make this:

function getBodyString(body: unknown, _logger: Logger = logger) {

so default this to the regular logger, then we do not need to guard this in the code!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, let's get rid of this here and just use the regular logger.log() instead! Make the type for the argument logger?: Logger which should also match for replay logger, I suppose, as that extends the core logger?

I think that makes the most sense. But apparently, ReplayLogger has always been extended from LoggerConsoleMethods instead of the core Logger:

type ReplayConsoleLevels = Extract<ConsoleLevel, 'info' | 'warn' | 'error' | 'log'>;
const CONSOLE_LEVELS: readonly ReplayConsoleLevels[] = ['info', 'warn', 'error', 'log'] as const;
const PREFIX = '[Replay] ';
type LoggerMethod = (...args: unknown[]) => void;
type LoggerConsoleMethods = Record<ReplayConsoleLevels, LoggerMethod>;
interface LoggerConfig {
captureExceptions: boolean;
traceInternals: boolean;
}
interface ReplayLogger extends LoggerConsoleMethods {

It's a decision i'm not aware of (it could've inherited from Logger since it's written), so i'm not sure about the implication of switching to Logger as the base class.

I tried the switch locally - both the unit tests (in replay-internal) and the integration/e2e tests pass, so it seems to not have caused any issue.

If this switch is considered OK, then I'll proceed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be fine! making this just accept Logger seems right to me and should be OK for replay too :)

/**
* Calls `logger.info` but saves breadcrumb in the next tick due to race
* conditions before replay is initialized.
*/
infoTick: LoggerMethod;
/**
* Captures exceptions (`Error`) if "capture internal exceptions" is enabled
*/
exception: LoggerMethod;
/**
* Configures the logger with additional debugging behavior
*/
setConfig(config: Partial<LoggerConfig>): void;
}

function _serializeFormData(formData: FormData): string {
// This is a bit simplified, but gives us a decent estimate
// This converts e.g. { name: 'Anne Smith', age: 13 } to 'name=Anne+Smith&age=13'
// @ts-expect-error passing FormData to URLSearchParams actually works
return new URLSearchParams(formData).toString();
}

/** Get the string representation of a body. */
export function getBodyString(
body: unknown,
logger?: Logger | ReplayLogger,
): [string | undefined, NetworkMetaWarning?] {
try {
if (typeof body === 'string') {
return [body];
}

if (body instanceof URLSearchParams) {
return [body.toString()];
}

if (body instanceof FormData) {
return [_serializeFormData(body)];
}

if (!body) {
return [undefined];
}
} catch (error) {
// RelayLogger
if (DEBUG_BUILD && logger && 'exception' in logger) {
logger.exception(error, 'Failed to serialize body', body);
} else if (DEBUG_BUILD && logger) {
logger.error(error, 'Failed to serialize body', body);
}
return [undefined, 'BODY_PARSE_ERROR'];
}

DEBUG_BUILD && logger?.info('Skipping network body because of body type', body);

return [undefined, 'UNPARSEABLE_BODY_TYPE'];
}
25 changes: 25 additions & 0 deletions packages/browser-utils/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
import type {
FetchBreadcrumbHint,
HandlerDataFetch,
SentryWrappedXMLHttpRequest,
XhrBreadcrumbHint,
} from '@sentry/core';
import { GLOBAL_OBJ } from '@sentry/core';

export const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ &
// document is not available in all browser environments (webworkers). We make it optional so you have to explicitly check for it
Omit<Window, 'document'> &
Partial<Pick<Window, 'document'>>;

export type NetworkMetaWarning =
| 'MAYBE_JSON_TRUNCATED'
| 'TEXT_TRUNCATED'
| 'URL_SKIPPED'
| 'BODY_PARSE_ERROR'
| 'BODY_PARSE_TIMEOUT'
| 'UNPARSEABLE_BODY_TYPE';

type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string;

export type XhrHint = XhrBreadcrumbHint & {
xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest;
input?: RequestBody;
};
export type FetchHint = FetchBreadcrumbHint & {
input: HandlerDataFetch['args'];
response: Response;
};
Loading
Loading