= {};
+
+ if (url) {
+ data.url = url;
+ }
+ if (query) {
+ data['http.query'] = query;
+ }
+ if (fragment) {
+ data['http.fragment'] = fragment;
+ }
+
+ return {
+ op: opParts.join('.'),
+ description,
+ source,
+ data,
+ };
+}
+
+/** Exported for tests only */
+export function getSanitizedUrl(
+ attributes: Attributes,
+ kind: SpanKind,
+): {
+ url: string | undefined;
+ urlPath: string | undefined;
+ query: string | undefined;
+ fragment: string | undefined;
+} {
+ // This is the relative path of the URL, e.g. /sub
+ const httpTarget = attributes[SemanticAttributes.HTTP_TARGET];
+ // This is the full URL, including host & query params etc., e.g. https://example.com/sub?foo=bar
+ const httpUrl = attributes[SemanticAttributes.HTTP_URL];
+ // This is the normalized route name - may not always be available!
+ const httpRoute = attributes[SemanticAttributes.HTTP_ROUTE];
+
+ const parsedUrl = typeof httpUrl === 'string' ? parseUrl(httpUrl) : undefined;
+ const url = parsedUrl ? getSanitizedUrlString(parsedUrl) : undefined;
+ const query = parsedUrl && parsedUrl.search ? parsedUrl.search : undefined;
+ const fragment = parsedUrl && parsedUrl.hash ? parsedUrl.hash : undefined;
+
+ if (typeof httpRoute === 'string') {
+ return { urlPath: httpRoute, url, query, fragment };
+ }
+
+ if (kind === SpanKind.SERVER && typeof httpTarget === 'string') {
+ return { urlPath: stripUrlQueryAndFragment(httpTarget), url, query, fragment };
+ }
+
+ if (parsedUrl) {
+ return { urlPath: url, url, query, fragment };
+ }
+
+ // fall back to target even for client spans, if no URL is present
+ if (typeof httpTarget === 'string') {
+ return { urlPath: stripUrlQueryAndFragment(httpTarget), url, query, fragment };
+ }
- return { op: opParts.join('.'), description, source };
+ return { urlPath: undefined, url, query, fragment };
}
diff --git a/packages/opentelemetry-node/test/spanprocessor.test.ts b/packages/opentelemetry-node/test/spanprocessor.test.ts
index cde0c7338d00..38c72ef5d3b8 100644
--- a/packages/opentelemetry-node/test/spanprocessor.test.ts
+++ b/packages/opentelemetry-node/test/spanprocessor.test.ts
@@ -434,10 +434,19 @@ describe('SentrySpanProcessor', () => {
child.setAttribute(SemanticAttributes.HTTP_METHOD, 'GET');
child.setAttribute(SemanticAttributes.HTTP_ROUTE, '/my/route/{id}');
child.setAttribute(SemanticAttributes.HTTP_TARGET, '/my/route/123');
+ child.setAttribute(SemanticAttributes.HTTP_URL, 'http://example.com/my/route/123');
child.end();
expect(sentrySpan?.description).toBe('GET /my/route/{id}');
+ expect(sentrySpan?.data).toEqual({
+ 'http.method': 'GET',
+ 'http.route': '/my/route/{id}',
+ 'http.target': '/my/route/123',
+ 'http.url': 'http://example.com/my/route/123',
+ 'otel.kind': 'INTERNAL',
+ url: 'http://example.com/my/route/123',
+ });
parentOtelSpan.end();
});
@@ -453,10 +462,47 @@ describe('SentrySpanProcessor', () => {
child.setAttribute(SemanticAttributes.HTTP_METHOD, 'GET');
child.setAttribute(SemanticAttributes.HTTP_TARGET, '/my/route/123');
+ child.setAttribute(SemanticAttributes.HTTP_URL, 'http://example.com/my/route/123');
child.end();
- expect(sentrySpan?.description).toBe('GET /my/route/123');
+ expect(sentrySpan?.description).toBe('GET http://example.com/my/route/123');
+ expect(sentrySpan?.data).toEqual({
+ 'http.method': 'GET',
+ 'http.target': '/my/route/123',
+ 'http.url': 'http://example.com/my/route/123',
+ 'otel.kind': 'INTERNAL',
+ url: 'http://example.com/my/route/123',
+ });
+
+ parentOtelSpan.end();
+ });
+ });
+ });
+
+ it('Adds query & hash data based on HTTP_URL', async () => {
+ const tracer = provider.getTracer('default');
+
+ tracer.startActiveSpan('GET /users', parentOtelSpan => {
+ tracer.startActiveSpan('HTTP GET', child => {
+ const sentrySpan = getSpanForOtelSpan(child);
+
+ child.setAttribute(SemanticAttributes.HTTP_METHOD, 'GET');
+ child.setAttribute(SemanticAttributes.HTTP_TARGET, '/my/route/123');
+ child.setAttribute(SemanticAttributes.HTTP_URL, 'http://example.com/my/route/123?what=123#myHash');
+
+ child.end();
+
+ expect(sentrySpan?.description).toBe('GET http://example.com/my/route/123');
+ expect(sentrySpan?.data).toEqual({
+ 'http.method': 'GET',
+ 'http.target': '/my/route/123',
+ 'http.url': 'http://example.com/my/route/123?what=123#myHash',
+ 'otel.kind': 'INTERNAL',
+ url: 'http://example.com/my/route/123',
+ 'http.query': '?what=123',
+ 'http.fragment': '#myHash',
+ });
parentOtelSpan.end();
});
diff --git a/packages/opentelemetry-node/test/utils/parseOtelSpanDescription.test.ts b/packages/opentelemetry-node/test/utils/parseOtelSpanDescription.test.ts
new file mode 100644
index 000000000000..b2d1b3654500
--- /dev/null
+++ b/packages/opentelemetry-node/test/utils/parseOtelSpanDescription.test.ts
@@ -0,0 +1,144 @@
+import { SpanKind } from '@opentelemetry/api';
+import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
+
+import { getSanitizedUrl } from '../../src/utils/parseOtelSpanDescription';
+
+describe('getSanitizedUrl', () => {
+ it.each([
+ [
+ 'works without attributes',
+ {},
+ SpanKind.CLIENT,
+ {
+ urlPath: undefined,
+ url: undefined,
+ fragment: undefined,
+ query: undefined,
+ },
+ ],
+ [
+ 'uses url without query for client request',
+ {
+ [SemanticAttributes.HTTP_URL]: 'http://example.com/?what=true',
+ [SemanticAttributes.HTTP_METHOD]: 'GET',
+ [SemanticAttributes.HTTP_TARGET]: '/?what=true',
+ [SemanticAttributes.HTTP_HOST]: 'example.com:80',
+ [SemanticAttributes.HTTP_STATUS_CODE]: 200,
+ },
+ SpanKind.CLIENT,
+ {
+ urlPath: 'http://example.com/',
+ url: 'http://example.com/',
+ fragment: undefined,
+ query: '?what=true',
+ },
+ ],
+ [
+ 'uses url without hash for client request',
+ {
+ [SemanticAttributes.HTTP_URL]: 'http://example.com/sub#hash',
+ [SemanticAttributes.HTTP_METHOD]: 'GET',
+ [SemanticAttributes.HTTP_TARGET]: '/sub#hash',
+ [SemanticAttributes.HTTP_HOST]: 'example.com:80',
+ [SemanticAttributes.HTTP_STATUS_CODE]: 200,
+ },
+ SpanKind.CLIENT,
+ {
+ urlPath: 'http://example.com/sub',
+ url: 'http://example.com/sub',
+ fragment: '#hash',
+ query: undefined,
+ },
+ ],
+ [
+ 'uses route if available for client request',
+ {
+ [SemanticAttributes.HTTP_URL]: 'http://example.com/?what=true',
+ [SemanticAttributes.HTTP_METHOD]: 'GET',
+ [SemanticAttributes.HTTP_TARGET]: '/?what=true',
+ [SemanticAttributes.HTTP_ROUTE]: '/my-route',
+ [SemanticAttributes.HTTP_HOST]: 'example.com:80',
+ [SemanticAttributes.HTTP_STATUS_CODE]: 200,
+ },
+ SpanKind.CLIENT,
+ {
+ urlPath: '/my-route',
+ url: 'http://example.com/',
+ fragment: undefined,
+ query: '?what=true',
+ },
+ ],
+ [
+ 'falls back to target for client request if url not available',
+ {
+ [SemanticAttributes.HTTP_METHOD]: 'GET',
+ [SemanticAttributes.HTTP_TARGET]: '/?what=true',
+ [SemanticAttributes.HTTP_HOST]: 'example.com:80',
+ [SemanticAttributes.HTTP_STATUS_CODE]: 200,
+ },
+ SpanKind.CLIENT,
+ {
+ urlPath: '/',
+ url: undefined,
+ fragment: undefined,
+ query: undefined,
+ },
+ ],
+ [
+ 'uses target without query for server request',
+ {
+ [SemanticAttributes.HTTP_URL]: 'http://example.com/?what=true',
+ [SemanticAttributes.HTTP_METHOD]: 'GET',
+ [SemanticAttributes.HTTP_TARGET]: '/?what=true',
+ [SemanticAttributes.HTTP_HOST]: 'example.com:80',
+ [SemanticAttributes.HTTP_STATUS_CODE]: 200,
+ },
+ SpanKind.SERVER,
+ {
+ urlPath: '/',
+ url: 'http://example.com/',
+ fragment: undefined,
+ query: '?what=true',
+ },
+ ],
+ [
+ 'uses target without hash for server request',
+ {
+ [SemanticAttributes.HTTP_URL]: 'http://example.com/?what=true',
+ [SemanticAttributes.HTTP_METHOD]: 'GET',
+ [SemanticAttributes.HTTP_TARGET]: '/sub#hash',
+ [SemanticAttributes.HTTP_HOST]: 'example.com:80',
+ [SemanticAttributes.HTTP_STATUS_CODE]: 200,
+ },
+ SpanKind.SERVER,
+ {
+ urlPath: '/sub',
+ url: 'http://example.com/',
+ fragment: undefined,
+ query: '?what=true',
+ },
+ ],
+ [
+ 'uses route for server request if available',
+ {
+ [SemanticAttributes.HTTP_URL]: 'http://example.com/?what=true',
+ [SemanticAttributes.HTTP_METHOD]: 'GET',
+ [SemanticAttributes.HTTP_TARGET]: '/?what=true',
+ [SemanticAttributes.HTTP_ROUTE]: '/my-route',
+ [SemanticAttributes.HTTP_HOST]: 'example.com:80',
+ [SemanticAttributes.HTTP_STATUS_CODE]: 200,
+ },
+ SpanKind.SERVER,
+ {
+ urlPath: '/my-route',
+ url: 'http://example.com/',
+ fragment: undefined,
+ query: '?what=true',
+ },
+ ],
+ ])('%s', (_, attributes, kind, expected) => {
+ const actual = getSanitizedUrl(attributes, kind);
+
+ expect(actual).toEqual(expected);
+ });
+});
diff --git a/packages/remix/src/client/errors.tsx b/packages/remix/src/client/errors.tsx
new file mode 100644
index 000000000000..9c9fd5c4b449
--- /dev/null
+++ b/packages/remix/src/client/errors.tsx
@@ -0,0 +1,65 @@
+import { captureException, withScope } from '@sentry/core';
+import { addExceptionMechanism, isNodeEnv, isString } from '@sentry/utils';
+
+import type { ErrorResponse } from '../utils/vendor/types';
+
+/**
+ * Checks whether the given error is an ErrorResponse.
+ * ErrorResponse is when users throw a response from their loader or action functions.
+ * This is in fact a server-side error that we capture on the client.
+ *
+ * @param error The error to check.
+ * @returns boolean
+ */
+function isErrorResponse(error: unknown): error is ErrorResponse {
+ return typeof error === 'object' && error !== null && 'status' in error && 'statusText' in error;
+}
+
+/**
+ * Captures an error that is thrown inside a Remix ErrorBoundary.
+ *
+ * @param error The error to capture.
+ * @returns void
+ */
+export function captureRemixErrorBoundaryError(error: unknown): void {
+ const isClientSideRuntimeError = !isNodeEnv() && error instanceof Error;
+ const isRemixErrorResponse = isErrorResponse(error);
+ // Server-side errors apart from `ErrorResponse`s also appear here without their stacktraces.
+ // So, we only capture:
+ // 1. `ErrorResponse`s
+ // 2. Client-side runtime errors here,
+ // And other server - side errors in `handleError` function where stacktraces are available.
+ if (isRemixErrorResponse || isClientSideRuntimeError) {
+ const eventData = isRemixErrorResponse
+ ? {
+ function: 'ErrorResponse',
+ ...error.data,
+ }
+ : {
+ function: 'ReactError',
+ };
+
+ withScope(scope => {
+ scope.addEventProcessor(event => {
+ addExceptionMechanism(event, {
+ type: 'instrument',
+ handled: true,
+ data: eventData,
+ });
+ return event;
+ });
+
+ if (isRemixErrorResponse) {
+ if (isString(error.data)) {
+ captureException(error.data);
+ } else if (error.statusText) {
+ captureException(error.statusText);
+ } else {
+ captureException(error);
+ }
+ } else {
+ captureException(error);
+ }
+ });
+ }
+}
diff --git a/packages/remix/src/performance/client.tsx b/packages/remix/src/client/performance.tsx
similarity index 95%
rename from packages/remix/src/performance/client.tsx
rename to packages/remix/src/client/performance.tsx
index 879c93e51f42..a3f7815b7bdc 100644
--- a/packages/remix/src/performance/client.tsx
+++ b/packages/remix/src/client/performance.tsx
@@ -4,6 +4,8 @@ import type { Transaction, TransactionContext } from '@sentry/types';
import { isNodeEnv, logger } from '@sentry/utils';
import * as React from 'react';
+import { getFutureFlagsBrowser } from '../utils/futureFlags';
+
const DEFAULT_TAGS = {
'routing.instrumentation': 'remix-router',
} as const;
@@ -93,7 +95,8 @@ export function withSentry, R extends React.FC
wrapWithErrorBoundary?: boolean;
errorBoundaryOptions?: ErrorBoundaryProps;
} = {
- wrapWithErrorBoundary: true,
+ // We don't want to wrap application with Sentry's ErrorBoundary by default for Remix v2
+ wrapWithErrorBoundary: getFutureFlagsBrowser()?.v2_errorBoundary ? false : true,
errorBoundaryOptions: {},
},
): R {
diff --git a/packages/remix/src/index.client.tsx b/packages/remix/src/index.client.tsx
index 5c76ee4907bf..64951a3f10cd 100644
--- a/packages/remix/src/index.client.tsx
+++ b/packages/remix/src/index.client.tsx
@@ -3,7 +3,8 @@ import { configureScope, init as reactInit } from '@sentry/react';
import { buildMetadata } from './utils/metadata';
import type { RemixOptions } from './utils/remixOptions';
-export { remixRouterInstrumentation, withSentry } from './performance/client';
+export { remixRouterInstrumentation, withSentry } from './client/performance';
+export { captureRemixErrorBoundaryError } from './client/errors';
export * from '@sentry/react';
export function init(options: RemixOptions): void {
diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts
index 91839a809993..4c37351001c2 100644
--- a/packages/remix/src/index.server.ts
+++ b/packages/remix/src/index.server.ts
@@ -54,8 +54,10 @@ export {
// Keeping the `*` exports for backwards compatibility and types
export * from '@sentry/node';
+export { captureRemixServerException } from './utils/instrumentServer';
export { ErrorBoundary, withErrorBoundary } from '@sentry/react';
-export { remixRouterInstrumentation, withSentry } from './performance/client';
+export { remixRouterInstrumentation, withSentry } from './client/performance';
+export { captureRemixErrorBoundaryError } from './client/errors';
export { wrapExpressCreateRequestHandler } from './utils/serverAdapters/express';
function sdkAlreadyInitialized(): boolean {
diff --git a/packages/remix/src/utils/futureFlags.ts b/packages/remix/src/utils/futureFlags.ts
new file mode 100644
index 000000000000..7d797c19e8a2
--- /dev/null
+++ b/packages/remix/src/utils/futureFlags.ts
@@ -0,0 +1,35 @@
+import { GLOBAL_OBJ } from '@sentry/utils';
+
+import type { FutureConfig, ServerBuild } from './vendor/types';
+
+export type EnhancedGlobal = typeof GLOBAL_OBJ & {
+ __remixContext?: {
+ future?: FutureConfig;
+ };
+};
+
+/**
+ * Get the future flags from the Remix browser context
+ *
+ * @returns The future flags
+ */
+export function getFutureFlagsBrowser(): FutureConfig | undefined {
+ const window = GLOBAL_OBJ as EnhancedGlobal;
+
+ if (!window.__remixContext) {
+ return;
+ }
+
+ return window.__remixContext.future;
+}
+
+/**
+ * Get the future flags from the Remix server build
+ *
+ * @param build The Remix server build
+ *
+ * @returns The future flags
+ */
+export function getFutureFlagsServer(build: ServerBuild): FutureConfig | undefined {
+ return build.future;
+}
diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/utils/instrumentServer.ts
index 64dea4cfb92b..b0ba45f69c26 100644
--- a/packages/remix/src/utils/instrumentServer.ts
+++ b/packages/remix/src/utils/instrumentServer.ts
@@ -13,12 +13,15 @@ import {
tracingContextFromHeaders,
} from '@sentry/utils';
+import { getFutureFlagsServer } from './futureFlags';
+import { extractData, getRequestMatch, isDeferredData, isResponse, json, matchServerRoutes } from './vendor/response';
import type {
AppData,
CreateRequestHandlerFunction,
DataFunction,
DataFunctionArgs,
EntryContext,
+ FutureConfig,
HandleDocumentRequestFunction,
ReactRouterDomPkg,
RemixRequest,
@@ -26,10 +29,11 @@ import type {
ServerBuild,
ServerRoute,
ServerRouteManifest,
-} from './types';
-import { extractData, getRequestMatch, isDeferredData, isResponse, json, matchServerRoutes } from './vendor/response';
+} from './vendor/types';
import { normalizeRemixRequest } from './web-fetch';
+let FUTURE_FLAGS: FutureConfig | undefined;
+
// Flag to track if the core request handler is instrumented.
export let isRequestHandlerWrapped = false;
@@ -56,7 +60,16 @@ async function extractResponseError(response: Response): Promise {
return responseData;
}
-async function captureRemixServerException(err: unknown, name: string, request: Request): Promise {
+/**
+ * Captures an exception happened in the Remix server.
+ *
+ * @param err The error to capture.
+ * @param name The name of the origin function.
+ * @param request The request object.
+ *
+ * @returns A promise that resolves when the exception is captured.
+ */
+export async function captureRemixServerException(err: unknown, name: string, request: Request): Promise {
// Skip capturing if the thrown error is not a 5xx response
// https://remix.run/docs/en/v1/api/conventions#throwing-responses-in-loaders
if (isResponse(err) && err.status < 500) {
@@ -145,7 +158,10 @@ function makeWrappedDocumentRequestFunction(
span?.finish();
} catch (err) {
- await captureRemixServerException(err, 'documentRequest', request);
+ if (!FUTURE_FLAGS?.v2_errorBoundary) {
+ await captureRemixServerException(err, 'documentRequest', request);
+ }
+
throw err;
}
@@ -182,7 +198,10 @@ function makeWrappedDataFunction(origFn: DataFunction, id: string, name: 'action
currentScope.setSpan(activeTransaction);
span?.finish();
} catch (err) {
- await captureRemixServerException(err, name, args.request);
+ if (!FUTURE_FLAGS?.v2_errorBoundary) {
+ await captureRemixServerException(err, name, args.request);
+ }
+
throw err;
}
@@ -439,6 +458,7 @@ function makeWrappedCreateRequestHandler(
isRequestHandlerWrapped = true;
return function (this: unknown, build: ServerBuild, ...args: unknown[]): RequestHandler {
+ FUTURE_FLAGS = getFutureFlagsServer(build);
const newBuild = instrumentBuild(build);
const requestHandler = origCreateRequestHandler.call(this, newBuild, ...args);
diff --git a/packages/remix/src/utils/serverAdapters/express.ts b/packages/remix/src/utils/serverAdapters/express.ts
index 000ad3a00b15..742c938f2d06 100644
--- a/packages/remix/src/utils/serverAdapters/express.ts
+++ b/packages/remix/src/utils/serverAdapters/express.ts
@@ -20,7 +20,7 @@ import type {
ExpressResponse,
ReactRouterDomPkg,
ServerBuild,
-} from '../types';
+} from '../vendor/types';
let pkg: ReactRouterDomPkg;
diff --git a/packages/remix/src/utils/vendor/response.ts b/packages/remix/src/utils/vendor/response.ts
index ae85fff74734..fed25dd0f534 100644
--- a/packages/remix/src/utils/vendor/response.ts
+++ b/packages/remix/src/utils/vendor/response.ts
@@ -6,7 +6,7 @@
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-import type { DeferredData, ReactRouterDomPkg, RouteMatch, ServerRoute } from '../types';
+import type { DeferredData, ReactRouterDomPkg, RouteMatch, ServerRoute } from './types';
/**
* Based on Remix Implementation
diff --git a/packages/remix/src/utils/types.ts b/packages/remix/src/utils/vendor/types.ts
similarity index 89%
rename from packages/remix/src/utils/types.ts
rename to packages/remix/src/utils/vendor/types.ts
index 74dcf10215cc..faaa7e5f6f60 100644
--- a/packages/remix/src/utils/types.ts
+++ b/packages/remix/src/utils/vendor/types.ts
@@ -14,6 +14,42 @@ import type * as Express from 'express';
import type { Agent } from 'https';
import type { ComponentType } from 'react';
+type Dev = {
+ command?: string;
+ scheme?: string;
+ host?: string;
+ port?: number;
+ restart?: boolean;
+ tlsKey?: string;
+ tlsCert?: string;
+};
+
+export interface FutureConfig {
+ unstable_dev: boolean | Dev;
+ /** @deprecated Use the `postcss` config option instead */
+ unstable_postcss: boolean;
+ /** @deprecated Use the `tailwind` config option instead */
+ unstable_tailwind: boolean;
+ v2_errorBoundary: boolean;
+ v2_headers: boolean;
+ v2_meta: boolean;
+ v2_normalizeFormMethod: boolean;
+ v2_routeConvention: boolean;
+}
+
+export interface RemixConfig {
+ [key: string]: any;
+ future: FutureConfig;
+}
+
+export interface ErrorResponse {
+ status: number;
+ statusText: string;
+ data: any;
+ error?: Error;
+ internal: boolean;
+}
+
export type RemixRequestState = {
method: string;
redirect: RequestRedirect;
@@ -133,6 +169,7 @@ export interface ServerBuild {
assets: AssetsManifest;
publicPath?: string;
assetsBuildDirectory?: string;
+ future?: FutureConfig;
}
export interface HandleDocumentRequestFunction {
diff --git a/packages/remix/src/utils/web-fetch.ts b/packages/remix/src/utils/web-fetch.ts
index 1e69a77b5dba..1961329c2f4b 100644
--- a/packages/remix/src/utils/web-fetch.ts
+++ b/packages/remix/src/utils/web-fetch.ts
@@ -24,8 +24,8 @@
import { logger } from '@sentry/utils';
-import type { RemixRequest } from './types';
import { getClientIPAddress } from './vendor/getIpAddress';
+import type { RemixRequest } from './vendor/types';
/*
* Symbol extractor utility to be able to access internal fields of Remix requests.
diff --git a/packages/remix/test/integration/app_v2/entry.server.tsx b/packages/remix/test/integration/app_v2/entry.server.tsx
index d48f2644fac4..784cb2a39cd4 100644
--- a/packages/remix/test/integration/app_v2/entry.server.tsx
+++ b/packages/remix/test/integration/app_v2/entry.server.tsx
@@ -1,4 +1,4 @@
-import type { EntryContext } from '@remix-run/node';
+import type { EntryContext, DataFunctionArgs } from '@remix-run/node';
import { RemixServer } from '@remix-run/react';
import { renderToString } from 'react-dom/server';
import * as Sentry from '@sentry/remix';
@@ -11,6 +11,14 @@ Sentry.init({
autoSessionTracking: false,
});
+export function handleError(error: unknown, { request }: DataFunctionArgs): void {
+ if (error instanceof Error) {
+ Sentry.captureRemixServerException(error, 'remix.server', request);
+ } else {
+ Sentry.captureException(error);
+ }
+}
+
export default function handleRequest(
request: Request,
responseStatusCode: number,
diff --git a/packages/remix/test/integration/app_v2/root.tsx b/packages/remix/test/integration/app_v2/root.tsx
index 5af1f8cc7a1a..2320451cee74 100644
--- a/packages/remix/test/integration/app_v2/root.tsx
+++ b/packages/remix/test/integration/app_v2/root.tsx
@@ -1,6 +1,15 @@
import { V2_MetaFunction, LoaderFunction, json, defer, redirect } from '@remix-run/node';
-import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react';
-import { withSentry } from '@sentry/remix';
+import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration, useRouteError } from '@remix-run/react';
+import { V2_ErrorBoundaryComponent } from '@remix-run/react/dist/routeModules';
+import { captureRemixErrorBoundaryError, withSentry } from '@sentry/remix';
+
+export const ErrorBoundary: V2_ErrorBoundaryComponent = () => {
+ const error = useRouteError();
+
+ captureRemixErrorBoundaryError(error);
+
+ return error
;
+};
export const meta: V2_MetaFunction = ({ data }) => [
{ charset: 'utf-8' },
diff --git a/packages/remix/test/integration/common/routes/action-json-response.$id.tsx b/packages/remix/test/integration/common/routes/action-json-response.$id.tsx
index 2c7a19059596..ff0f6940fe44 100644
--- a/packages/remix/test/integration/common/routes/action-json-response.$id.tsx
+++ b/packages/remix/test/integration/common/routes/action-json-response.$id.tsx
@@ -3,8 +3,10 @@ import { useActionData } from '@remix-run/react';
export const loader: LoaderFunction = async ({ params: { id } }) => {
if (id === '-1') {
- throw new Error('Unexpected Server Error from Loader');
+ throw new Error('Unexpected Server Error');
}
+
+ return null;
};
export const action: ActionFunction = async ({ params: { id } }) => {
diff --git a/packages/remix/test/integration/common/routes/loader-json-response.$id.tsx b/packages/remix/test/integration/common/routes/loader-json-response.$id.tsx
index 55b53e2d70dc..a4ad3dc48339 100644
--- a/packages/remix/test/integration/common/routes/loader-json-response.$id.tsx
+++ b/packages/remix/test/integration/common/routes/loader-json-response.$id.tsx
@@ -5,7 +5,7 @@ type LoaderData = { id: string };
export const loader: LoaderFunction = async ({ params: { id } }) => {
if (id === '-2') {
- throw new Error('Unexpected Server Error from Loader');
+ throw new Error('Unexpected Server Error');
}
if (id === '-1') {
diff --git a/packages/remix/test/integration/test/client/errorboundary.test.ts b/packages/remix/test/integration/test/client/errorboundary.test.ts
index b90b3e8d3eaa..9c496e3e3040 100644
--- a/packages/remix/test/integration/test/client/errorboundary.test.ts
+++ b/packages/remix/test/integration/test/client/errorboundary.test.ts
@@ -21,16 +21,20 @@ test('should capture React component errors.', async ({ page }) => {
expect(errorEnvelope.level).toBe('error');
expect(errorEnvelope.sdk?.name).toBe('sentry.javascript.remix');
expect(errorEnvelope.exception?.values).toMatchObject([
- {
- type: 'React ErrorBoundary Error',
- value: 'Sentry React Component Error',
- stacktrace: { frames: expect.any(Array) },
- },
+ ...(!useV2
+ ? [
+ {
+ type: 'React ErrorBoundary Error',
+ value: 'Sentry React Component Error',
+ stacktrace: { frames: expect.any(Array) },
+ },
+ ]
+ : []),
{
type: 'Error',
value: 'Sentry React Component Error',
stacktrace: { frames: expect.any(Array) },
- mechanism: { type: 'generic', handled: true },
+ mechanism: { type: useV2 ? 'instrument' : 'generic', handled: true },
},
]);
});
diff --git a/packages/remix/test/integration/test/client/pageload.test.ts b/packages/remix/test/integration/test/client/pageload.test.ts
index 7c49e4ac9c8c..59a8e331668e 100644
--- a/packages/remix/test/integration/test/client/pageload.test.ts
+++ b/packages/remix/test/integration/test/client/pageload.test.ts
@@ -4,7 +4,12 @@ import { getFirstSentryEnvelopeRequest } from './utils/helpers';
import { test, expect } from '@playwright/test';
import { Event } from '@sentry/types';
-test('should add `pageload` transaction on load.', async ({ page }) => {
+test('should add `pageload` transaction on load.', async ({ page, browserName }) => {
+ // This test is flaky on firefox
+ if (browserName === 'firefox') {
+ test.skip();
+ }
+
const envelope = await getFirstSentryEnvelopeRequest(page, '/');
expect(envelope.contexts?.trace.op).toBe('pageload');
diff --git a/packages/remix/test/integration/test/server/action.test.ts b/packages/remix/test/integration/test/server/action.test.ts
index cc25a87611d4..a2a4632ba962 100644
--- a/packages/remix/test/integration/test/server/action.test.ts
+++ b/packages/remix/test/integration/test/server/action.test.ts
@@ -81,7 +81,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
stacktrace: expect.any(Object),
mechanism: {
data: {
- function: 'action',
+ function: useV2 ? 'remix.server' : 'action',
},
handled: true,
type: 'instrument',
@@ -197,11 +197,11 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
values: [
{
type: 'Error',
- value: 'Unexpected Server Error from Loader',
+ value: 'Unexpected Server Error',
stacktrace: expect.any(Object),
mechanism: {
data: {
- function: 'loader',
+ function: useV2 ? 'remix.server' : 'loader',
},
handled: true,
type: 'instrument',
@@ -254,7 +254,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
stacktrace: expect.any(Object),
mechanism: {
data: {
- function: 'action',
+ function: useV2 ? 'ErrorResponse' : 'action',
},
handled: true,
type: 'instrument',
@@ -303,11 +303,13 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
values: [
{
type: 'Error',
- value: 'Non-Error exception captured with keys: data',
+ value: useV2
+ ? 'Non-Error exception captured with keys: data, internal, status, statusText'
+ : 'Non-Error exception captured with keys: data',
stacktrace: expect.any(Object),
mechanism: {
data: {
- function: 'action',
+ function: useV2 ? 'ErrorResponse' : 'action',
},
handled: true,
type: 'instrument',
@@ -360,7 +362,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
stacktrace: expect.any(Object),
mechanism: {
data: {
- function: 'action',
+ function: useV2 ? 'ErrorResponse' : 'action',
},
handled: true,
type: 'instrument',
@@ -409,11 +411,13 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
values: [
{
type: 'Error',
- value: 'Non-Error exception captured with keys: [object has no keys]',
+ value: useV2
+ ? 'Non-Error exception captured with keys: data, internal, status, statusText'
+ : 'Non-Error exception captured with keys: [object has no keys]',
stacktrace: expect.any(Object),
mechanism: {
data: {
- function: 'action',
+ function: useV2 ? 'ErrorResponse' : 'action',
},
handled: true,
type: 'instrument',
diff --git a/packages/remix/test/integration/test/server/loader.test.ts b/packages/remix/test/integration/test/server/loader.test.ts
index 8a99c699cc37..ccaa93b05e36 100644
--- a/packages/remix/test/integration/test/server/loader.test.ts
+++ b/packages/remix/test/integration/test/server/loader.test.ts
@@ -35,11 +35,11 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada
values: [
{
type: 'Error',
- value: 'Unexpected Server Error from Loader',
+ value: 'Unexpected Server Error',
stacktrace: expect.any(Object),
mechanism: {
data: {
- function: 'loader',
+ function: useV2 ? 'remix.server' : 'loader',
},
handled: true,
type: 'instrument',
@@ -134,11 +134,11 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada
values: [
{
type: 'Error',
- value: 'Unexpected Server Error from Loader',
+ value: 'Unexpected Server Error',
stacktrace: expect.any(Object),
mechanism: {
data: {
- function: 'loader',
+ function: useV2 ? 'remix.server' : 'loader',
},
handled: true,
type: 'instrument',
diff --git a/packages/replay-worker/package.json b/packages/replay-worker/package.json
index 137a5e58c96a..2d1623dd8d20 100644
--- a/packages/replay-worker/package.json
+++ b/packages/replay-worker/package.json
@@ -22,9 +22,9 @@
"build:types:downlevel": "yarn downlevel-dts build/npm/types build/npm/types-ts3.8 --to ts3.8",
"build:dev": "yarn build",
"build:watch": "run-p build:transpile:watch build:types:watch",
- "build:dev:watch": "run-p build:watch",
+ "build:dev:watch": "yarn build:watch",
"build:transpile:watch": "yarn build:transpile --watch",
- "build:types:watch": "yarn build:types --watch",
+ "build:types:watch": "tsc -p tsconfig.types.json --watch",
"clean": "rimraf build",
"fix": "run-s fix:eslint fix:prettier",
"fix:eslint": "eslint . --format stylish --fix",
diff --git a/packages/replay/src/util/addEvent.ts b/packages/replay/src/util/addEvent.ts
index 9533c1690dd8..fdc755ada91c 100644
--- a/packages/replay/src/util/addEvent.ts
+++ b/packages/replay/src/util/addEvent.ts
@@ -3,7 +3,7 @@ import { getCurrentHub } from '@sentry/core';
import { logger } from '@sentry/utils';
import { EventBufferSizeExceededError } from '../eventBuffer/error';
-import type { AddEventResult, RecordingEvent, ReplayContainer, ReplayFrameEvent } from '../types';
+import type { AddEventResult, RecordingEvent, ReplayContainer, ReplayFrameEvent, ReplayPluginOptions } from '../types';
import { timestampToMs } from './timestampToMs';
function isCustomEvent(event: RecordingEvent): event is ReplayFrameEvent {
@@ -46,10 +46,7 @@ export async function addEvent(
const replayOptions = replay.getOptions();
- const eventAfterPossibleCallback =
- typeof replayOptions.beforeAddRecordingEvent === 'function' && isCustomEvent(event)
- ? replayOptions.beforeAddRecordingEvent(event)
- : event;
+ const eventAfterPossibleCallback = maybeApplyCallback(event, replayOptions.beforeAddRecordingEvent);
if (!eventAfterPossibleCallback) {
return;
@@ -69,3 +66,20 @@ export async function addEvent(
}
}
}
+
+function maybeApplyCallback(
+ event: RecordingEvent,
+ callback: ReplayPluginOptions['beforeAddRecordingEvent'],
+): RecordingEvent | null | undefined {
+ try {
+ if (typeof callback === 'function' && isCustomEvent(event)) {
+ return callback(event);
+ }
+ } catch (error) {
+ __DEBUG_BUILD__ &&
+ logger.error('[Replay] An error occured in the `beforeAddRecordingEvent` callback, skipping the event...', error);
+ return null;
+ }
+
+ return event;
+}
diff --git a/packages/replay/src/util/hasSessionStorage.ts b/packages/replay/src/util/hasSessionStorage.ts
index f242df101c25..e7be6788eb1a 100644
--- a/packages/replay/src/util/hasSessionStorage.ts
+++ b/packages/replay/src/util/hasSessionStorage.ts
@@ -2,5 +2,10 @@ import { WINDOW } from '../constants';
/** If sessionStorage is available. */
export function hasSessionStorage(): boolean {
- return 'sessionStorage' in WINDOW && !!WINDOW.sessionStorage;
+ try {
+ // This can throw, e.g. when being accessed in a sandboxed iframe
+ return 'sessionStorage' in WINDOW && !!WINDOW.sessionStorage;
+ } catch {
+ return false;
+ }
}
diff --git a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts
index af552e6104d8..6bf33f182e66 100644
--- a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts
+++ b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts
@@ -5,7 +5,8 @@ import * as SentryUtils from '@sentry/utils';
import type { Replay } from '../../src';
import type { ReplayContainer } from '../../src/replay';
import { clearSession } from '../../src/session/clearSession';
-import type { EventType } from '../../src/types';
+import { createPerformanceEntries } from '../../src/util/createPerformanceEntries';
+import { createPerformanceSpans } from '../../src/util/createPerformanceSpans';
import * as SendReplayRequest from '../../src/util/sendReplayRequest';
import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index';
import { useFakeTimers } from '../utils/use-fake-timers';
@@ -40,6 +41,10 @@ describe('Integration | beforeAddRecordingEvent', () => {
beforeAddRecordingEvent: event => {
const eventData = event.data;
+ if (eventData.tag === 'performanceSpan') {
+ throw new Error('test error in callback');
+ }
+
if (eventData.tag === 'breadcrumb' && eventData.payload.category === 'ui.click') {
return {
...event,
@@ -53,12 +58,6 @@ describe('Integration | beforeAddRecordingEvent', () => {
};
}
- // This should not do anything because callback should not be called
- // for `event.type != 5` - but we guard anyhow to be safe
- if ((event.type as EventType) === 2) {
- return null;
- }
-
if (eventData.tag === 'options') {
return null;
}
@@ -143,4 +142,42 @@ describe('Integration | beforeAddRecordingEvent', () => {
recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }]),
});
});
+
+ it('handles error in callback', async () => {
+ createPerformanceSpans(
+ replay,
+ createPerformanceEntries([
+ {
+ name: 'https://sentry.io/foo.js',
+ entryType: 'resource',
+ startTime: 176.59999990463257,
+ duration: 5.600000023841858,
+ initiatorType: 'link',
+ nextHopProtocol: 'h2',
+ workerStart: 177.5,
+ redirectStart: 0,
+ redirectEnd: 0,
+ fetchStart: 177.69999992847443,
+ domainLookupStart: 177.69999992847443,
+ domainLookupEnd: 177.69999992847443,
+ connectStart: 177.69999992847443,
+ connectEnd: 177.69999992847443,
+ secureConnectionStart: 177.69999992847443,
+ requestStart: 177.5,
+ responseStart: 181,
+ responseEnd: 182.19999992847443,
+ transferSize: 0,
+ encodedBodySize: 0,
+ decodedBodySize: 0,
+ serverTiming: [],
+ } as unknown as PerformanceResourceTiming,
+ ]),
+ );
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ expect(replay).not.toHaveLastSentReplay();
+ expect(replay.isEnabled()).toBe(true);
+ });
});
diff --git a/packages/tracing-internal/src/browser/browsertracing.ts b/packages/tracing-internal/src/browser/browsertracing.ts
index 4be633821a1a..13cde107074f 100644
--- a/packages/tracing-internal/src/browser/browsertracing.ts
+++ b/packages/tracing-internal/src/browser/browsertracing.ts
@@ -105,7 +105,6 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions {
_experiments: Partial<{
enableLongTask: boolean;
enableInteractions: boolean;
- enableHTTPTimings: boolean;
onStartRouteTransaction: (t: Transaction | undefined, ctx: TransactionContext, getCurrentHub: () => Hub) => void;
}>;
@@ -140,6 +139,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = {
startTransactionOnLocationChange: true,
startTransactionOnPageLoad: true,
enableLongTask: true,
+ _experiments: {},
...defaultRequestInstrumentationOptions,
};
@@ -230,6 +230,7 @@ export class BrowserTracing implements Integration {
traceFetch,
traceXHR,
shouldCreateSpanForRequest,
+ enableHTTPTimings,
_experiments,
} = this.options;
@@ -277,9 +278,7 @@ export class BrowserTracing implements Integration {
traceXHR,
tracePropagationTargets,
shouldCreateSpanForRequest,
- _experiments: {
- enableHTTPTimings: _experiments.enableHTTPTimings,
- },
+ enableHTTPTimings,
});
}
diff --git a/packages/tracing-internal/src/browser/request.ts b/packages/tracing-internal/src/browser/request.ts
index 071b2146bb16..2062ab5ce587 100644
--- a/packages/tracing-internal/src/browser/request.ts
+++ b/packages/tracing-internal/src/browser/request.ts
@@ -16,13 +16,6 @@ export const DEFAULT_TRACE_PROPAGATION_TARGETS = ['localhost', /^\/(?!\/)/];
/** Options for Request Instrumentation */
export interface RequestInstrumentationOptions {
- /**
- * Allow experiments for the request instrumentation.
- */
- _experiments: Partial<{
- enableHTTPTimings: boolean;
- }>;
-
/**
* @deprecated Will be removed in v8.
* Use `shouldCreateSpanForRequest` to control span creation and `tracePropagationTargets` to control
@@ -52,6 +45,13 @@ export interface RequestInstrumentationOptions {
*/
traceXHR: boolean;
+ /**
+ * If true, Sentry will capture http timings and add them to the corresponding http spans.
+ *
+ * Default: true
+ */
+ enableHTTPTimings: boolean;
+
/**
* This function will be called before creating a span for a request with the given url.
* Return false if you don't want a span for the given url.
@@ -114,16 +114,23 @@ type PolymorphicRequestHeaders =
export const defaultRequestInstrumentationOptions: RequestInstrumentationOptions = {
traceFetch: true,
traceXHR: true,
+ enableHTTPTimings: true,
// TODO (v8): Remove this property
tracingOrigins: DEFAULT_TRACE_PROPAGATION_TARGETS,
tracePropagationTargets: DEFAULT_TRACE_PROPAGATION_TARGETS,
- _experiments: {},
};
/** Registers span creators for xhr and fetch requests */
export function instrumentOutgoingRequests(_options?: Partial): void {
- // eslint-disable-next-line deprecation/deprecation
- const { traceFetch, traceXHR, tracePropagationTargets, tracingOrigins, shouldCreateSpanForRequest, _experiments } = {
+ const {
+ traceFetch,
+ traceXHR,
+ tracePropagationTargets,
+ // eslint-disable-next-line deprecation/deprecation
+ tracingOrigins,
+ shouldCreateSpanForRequest,
+ enableHTTPTimings,
+ } = {
traceFetch: defaultRequestInstrumentationOptions.traceFetch,
traceXHR: defaultRequestInstrumentationOptions.traceXHR,
..._options,
@@ -143,7 +150,7 @@ export function instrumentOutgoingRequests(_options?: Partial {
const createdSpan = fetchCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans);
- if (_experiments?.enableHTTPTimings && createdSpan) {
+ if (enableHTTPTimings && createdSpan) {
addHTTPTimings(createdSpan);
}
});
@@ -152,7 +159,7 @@ export function instrumentOutgoingRequests(_options?: Partial {
const createdSpan = xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans);
- if (_experiments?.enableHTTPTimings && createdSpan) {
+ if (enableHTTPTimings && createdSpan) {
addHTTPTimings(createdSpan);
}
});
@@ -182,22 +189,63 @@ function addHTTPTimings(span: Span): void {
});
}
+/**
+ * Converts ALPN protocol ids to name and version.
+ *
+ * (https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids)
+ * @param nextHopProtocol PerformanceResourceTiming.nextHopProtocol
+ */
+export function extractNetworkProtocol(nextHopProtocol: string): { name: string; version: string } {
+ let name = 'unknown';
+ let version = 'unknown';
+ let _name = '';
+ for (const char of nextHopProtocol) {
+ // http/1.1 etc.
+ if (char === '/') {
+ [name, version] = nextHopProtocol.split('/');
+ break;
+ }
+ // h2, h3 etc.
+ if (!isNaN(Number(char))) {
+ name = _name === 'h' ? 'http' : _name;
+ version = nextHopProtocol.split(_name)[1];
+ break;
+ }
+ _name += char;
+ }
+ if (_name === nextHopProtocol) {
+ // webrtc, ftp, etc.
+ name = _name;
+ }
+ return { name, version };
+}
+
+function getAbsoluteTime(time: number): number {
+ return ((browserPerformanceTimeOrigin || performance.timeOrigin) + time) / 1000;
+}
+
function resourceTimingEntryToSpanData(resourceTiming: PerformanceResourceTiming): [string, string | number][] {
- const version = resourceTiming.nextHopProtocol.split('/')[1] || 'none';
+ const { name, version } = extractNetworkProtocol(resourceTiming.nextHopProtocol);
const timingSpanData: [string, string | number][] = [];
- if (version) {
- timingSpanData.push(['network.protocol.version', version]);
- }
+
+ timingSpanData.push(['network.protocol.version', version], ['network.protocol.name', name]);
if (!browserPerformanceTimeOrigin) {
return timingSpanData;
}
return [
...timingSpanData,
- ['http.request.connect_start', (browserPerformanceTimeOrigin + resourceTiming.connectStart) / 1000],
- ['http.request.request_start', (browserPerformanceTimeOrigin + resourceTiming.requestStart) / 1000],
- ['http.request.response_start', (browserPerformanceTimeOrigin + resourceTiming.responseStart) / 1000],
+ ['http.request.redirect_start', getAbsoluteTime(resourceTiming.redirectStart)],
+ ['http.request.fetch_start', getAbsoluteTime(resourceTiming.fetchStart)],
+ ['http.request.domain_lookup_start', getAbsoluteTime(resourceTiming.domainLookupStart)],
+ ['http.request.domain_lookup_end', getAbsoluteTime(resourceTiming.domainLookupEnd)],
+ ['http.request.connect_start', getAbsoluteTime(resourceTiming.connectStart)],
+ ['http.request.secure_connection_start', getAbsoluteTime(resourceTiming.secureConnectionStart)],
+ ['http.request.connection_end', getAbsoluteTime(resourceTiming.connectEnd)],
+ ['http.request.request_start', getAbsoluteTime(resourceTiming.requestStart)],
+ ['http.request.response_start', getAbsoluteTime(resourceTiming.responseStart)],
+ ['http.request.response_end', getAbsoluteTime(resourceTiming.responseEnd)],
];
}
diff --git a/packages/tracing-internal/test/browser/browsertracing.test.ts b/packages/tracing-internal/test/browser/browsertracing.test.ts
index 0754afd65fc8..e6a9eff3fb82 100644
--- a/packages/tracing-internal/test/browser/browsertracing.test.ts
+++ b/packages/tracing-internal/test/browser/browsertracing.test.ts
@@ -95,6 +95,7 @@ conditionalTest({ min: 10 })('BrowserTracing', () => {
expect(browserTracing.options).toEqual({
enableLongTask: true,
+ _experiments: {},
...TRACING_DEFAULTS,
markBackgroundTransactions: true,
routingInstrumentation: instrumentRoutingWithDefaults,
@@ -132,6 +133,7 @@ conditionalTest({ min: 10 })('BrowserTracing', () => {
expect(browserTracing.options).toEqual({
enableLongTask: false,
+ _experiments: {},
...TRACING_DEFAULTS,
markBackgroundTransactions: true,
routingInstrumentation: instrumentRoutingWithDefaults,
@@ -246,7 +248,7 @@ conditionalTest({ min: 10 })('BrowserTracing', () => {
traceFetch: true,
traceXHR: true,
tracePropagationTargets: ['something'],
- _experiments: {},
+ enableHTTPTimings: true,
});
});
@@ -260,7 +262,7 @@ conditionalTest({ min: 10 })('BrowserTracing', () => {
});
expect(instrumentOutgoingRequestsMock).toHaveBeenCalledWith({
- _experiments: {},
+ enableHTTPTimings: true,
traceFetch: true,
traceXHR: true,
tracePropagationTargets: ['something-else'],
diff --git a/packages/tracing-internal/test/browser/request.test.ts b/packages/tracing-internal/test/browser/request.test.ts
index 9c8307e97fd7..992b50768428 100644
--- a/packages/tracing-internal/test/browser/request.test.ts
+++ b/packages/tracing-internal/test/browser/request.test.ts
@@ -7,7 +7,13 @@ import type { Transaction } from '../../../tracing/src';
import { addExtensionMethods, Span, spanStatusfromHttpCode } from '../../../tracing/src';
import { getDefaultBrowserClientOptions } from '../../../tracing/test/testutils';
import type { FetchData, XHRData } from '../../src/browser/request';
-import { fetchCallback, instrumentOutgoingRequests, shouldAttachHeaders, xhrCallback } from '../../src/browser/request';
+import {
+ extractNetworkProtocol,
+ fetchCallback,
+ instrumentOutgoingRequests,
+ shouldAttachHeaders,
+ xhrCallback,
+} from '../../src/browser/request';
import { TestClient } from '../utils/TestClient';
beforeAll(() => {
@@ -388,6 +394,57 @@ describe('callbacks', () => {
});
});
+interface ProtocolInfo {
+ name: string;
+ version: string;
+}
+
+describe('HTTPTimings', () => {
+ describe('Extracting version from ALPN protocol', () => {
+ const nextHopToNetworkVersion: Record = {
+ 'http/0.9': { name: 'http', version: '0.9' },
+ 'http/1.0': { name: 'http', version: '1.0' },
+ 'http/1.1': { name: 'http', version: '1.1' },
+ 'spdy/1': { name: 'spdy', version: '1' },
+ 'spdy/2': { name: 'spdy', version: '2' },
+ 'spdy/3': { name: 'spdy', version: '3' },
+ 'stun.turn': { name: 'stun.turn', version: 'unknown' },
+ 'stun.nat-discovery': { name: 'stun.nat-discovery', version: 'unknown' },
+ h2: { name: 'http', version: '2' },
+ h2c: { name: 'http', version: '2c' },
+ webrtc: { name: 'webrtc', version: 'unknown' },
+ 'c-webrtc': { name: 'c-webrtc', version: 'unknown' },
+ ftp: { name: 'ftp', version: 'unknown' },
+ imap: { name: 'imap', version: 'unknown' },
+ pop3: { name: 'pop', version: '3' },
+ managesieve: { name: 'managesieve', version: 'unknown' },
+ coap: { name: 'coap', version: 'unknown' },
+ 'xmpp-client': { name: 'xmpp-client', version: 'unknown' },
+ 'xmpp-server': { name: 'xmpp-server', version: 'unknown' },
+ 'acme-tls/1': { name: 'acme-tls', version: '1' },
+ mqtt: { name: 'mqtt', version: 'unknown' },
+ dot: { name: 'dot', version: 'unknown' },
+ 'ntske/1': { name: 'ntske', version: '1' },
+ sunrpc: { name: 'sunrpc', version: 'unknown' },
+ h3: { name: 'http', version: '3' },
+ smb: { name: 'smb', version: 'unknown' },
+ irc: { name: 'irc', version: 'unknown' },
+ nntp: { name: 'nntp', version: 'unknown' },
+ nnsp: { name: 'nnsp', version: 'unknown' },
+ doq: { name: 'doq', version: 'unknown' },
+ 'sip/2': { name: 'sip', version: '2' },
+ 'tds/8.0': { name: 'tds', version: '8.0' },
+ dicom: { name: 'dicom', version: 'unknown' },
+ };
+
+ const protocols = Object.keys(nextHopToNetworkVersion);
+ for (const protocol of protocols) {
+ const expected: ProtocolInfo = nextHopToNetworkVersion[protocol];
+ expect(extractNetworkProtocol(protocol)).toMatchObject(expected);
+ }
+ });
+});
+
describe('shouldAttachHeaders', () => {
describe('should prefer `tracePropagationTargets` over defaults', () => {
it('should return `true` if the url matches the new tracePropagationTargets', () => {