Skip to content

Commit a874d76

Browse files
authored
feat(cloudflare): Improve http span data (#16232)
While helping debug work in https://github.com/getsentry/sentry-mcp, I noticed that we didn't attach `url.path` to the http fetch spans, which made using the trace explorer harder to use. This PR updates the cloudflare http instrumentation (fetch handlers for regular workers and durable objects) to use a new `getHttpSpanDetailsFromUrlObject` abstraction I built. `getHttpSpanDetailsFromUrlObject` returns the http span name and it's associated attributes, taking care to handle source, route names, and handling both relative and full URLs. This is related to all the #15767 work I've been doing.
1 parent d273046 commit a874d76

File tree

5 files changed

+474
-48
lines changed

5 files changed

+474
-48
lines changed

packages/cloudflare/src/request.ts

+6-25
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
import type { ExecutionContext, IncomingRequestCfProperties } from '@cloudflare/workers-types';
2-
import type { SpanAttributes } from '@sentry/core';
32
import {
43
captureException,
54
continueTrace,
65
flush,
7-
SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD,
6+
getHttpSpanDetailsFromUrlObject,
7+
parseStringToURLObject,
88
SEMANTIC_ATTRIBUTE_SENTRY_OP,
9-
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
10-
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
11-
SEMANTIC_ATTRIBUTE_URL_FULL,
129
setHttpStatus,
1310
startSpan,
14-
stripUrlQueryAndFragment,
1511
withIsolationScope,
1612
} from '@sentry/core';
1713
import type { CloudflareOptions } from './client';
@@ -42,28 +38,15 @@ export function wrapRequestHandler(
4238
const client = init(options);
4339
isolationScope.setClient(client);
4440

45-
const attributes: SpanAttributes = {
46-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.cloudflare',
47-
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
48-
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
49-
[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: request.method,
50-
[SEMANTIC_ATTRIBUTE_URL_FULL]: request.url,
51-
};
41+
const urlObject = parseStringToURLObject(request.url);
42+
const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'auto.http.cloudflare', request);
5243

5344
const contentLength = request.headers.get('content-length');
5445
if (contentLength) {
5546
attributes['http.request.body.size'] = parseInt(contentLength, 10);
5647
}
5748

58-
let pathname = '';
59-
try {
60-
const url = new URL(request.url);
61-
pathname = url.pathname;
62-
attributes['server.address'] = url.hostname;
63-
attributes['url.scheme'] = url.protocol.replace(':', '');
64-
} catch {
65-
// skip
66-
}
49+
attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server';
6750

6851
addCloudResourceContext(isolationScope);
6952
if (request) {
@@ -74,8 +57,6 @@ export function wrapRequestHandler(
7457
}
7558
}
7659

77-
const routeName = `${request.method} ${pathname ? stripUrlQueryAndFragment(pathname) : '/'}`;
78-
7960
// Do not capture spans for OPTIONS and HEAD requests
8061
if (request.method === 'OPTIONS' || request.method === 'HEAD') {
8162
try {
@@ -96,7 +77,7 @@ export function wrapRequestHandler(
9677
// See: https://developers.cloudflare.com/workers/runtime-apis/performance/
9778
return startSpan(
9879
{
99-
name: routeName,
80+
name,
10081
attributes,
10182
},
10283
async span => {

packages/cloudflare/test/request.test.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -254,12 +254,13 @@ describe('withSentry', () => {
254254
data: {
255255
'sentry.origin': 'auto.http.cloudflare',
256256
'sentry.op': 'http.server',
257-
'sentry.source': 'url',
257+
'sentry.source': 'route',
258258
'http.request.method': 'GET',
259259
'url.full': 'https://example.com/',
260260
'server.address': 'example.com',
261261
'network.protocol.name': 'HTTP/1.1',
262-
'url.scheme': 'https',
262+
'url.scheme': 'https:',
263+
'url.path': '/',
263264
'sentry.sample_rate': 1,
264265
'http.response.status_code': 200,
265266
'http.request.body.size': 10,
@@ -269,6 +270,8 @@ describe('withSentry', () => {
269270
span_id: expect.stringMatching(/[a-f0-9]{16}/),
270271
status: 'ok',
271272
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
273+
parent_span_id: undefined,
274+
links: undefined,
272275
});
273276
});
274277
});

packages/core/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ export {
255255
parseUrl,
256256
stripUrlQueryAndFragment,
257257
parseStringToURLObject,
258+
getHttpSpanDetailsFromUrlObject,
258259
isURLObjectRelative,
259260
getSanitizedUrlStringFromUrlObject,
260261
} from './utils-hoist/url';

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

+98-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
import {
2+
SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD,
3+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
4+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
5+
SEMANTIC_ATTRIBUTE_URL_FULL,
6+
} from '../semanticAttributes';
7+
import type { SpanAttributes } from '../types-hoist/span';
8+
19
type PartialURL = {
210
host?: string;
311
path?: string;
@@ -53,7 +61,7 @@ export function isURLObjectRelative(url: URLObject): url is RelativeURL {
5361
* @returns The parsed URL object or undefined if the URL is invalid
5462
*/
5563
export function parseStringToURLObject(url: string, urlBase?: string | URL | undefined): URLObject | undefined {
56-
const isRelative = url.startsWith('/');
64+
const isRelative = url.indexOf('://') <= 0 && url.indexOf('//') !== 0;
5765
const base = urlBase ?? (isRelative ? DEFAULT_BASE_URL : undefined);
5866
try {
5967
// Use `canParse` to short-circuit the URL constructor if it's not a valid URL
@@ -107,6 +115,95 @@ export function getSanitizedUrlStringFromUrlObject(url: URLObject): string {
107115
return newUrl.toString();
108116
}
109117

118+
type PartialRequest = {
119+
method?: string;
120+
};
121+
122+
function getHttpSpanNameFromUrlObject(
123+
urlObject: URLObject | undefined,
124+
kind: 'server' | 'client',
125+
request?: PartialRequest,
126+
routeName?: string,
127+
): string {
128+
const method = request?.method?.toUpperCase() ?? 'GET';
129+
const route = routeName
130+
? routeName
131+
: urlObject
132+
? kind === 'client'
133+
? getSanitizedUrlStringFromUrlObject(urlObject)
134+
: urlObject.pathname
135+
: '/';
136+
137+
return `${method} ${route}`;
138+
}
139+
140+
/**
141+
* Takes a parsed URL object and returns a set of attributes for the span
142+
* that represents the HTTP request for that url. This is used for both server
143+
* and client http spans.
144+
*
145+
* Follows https://opentelemetry.io/docs/specs/semconv/http/.
146+
*
147+
* @param urlObject - see {@link parseStringToURLObject}
148+
* @param kind - The type of HTTP operation (server or client)
149+
* @param spanOrigin - The origin of the span
150+
* @param request - The request object, see {@link PartialRequest}
151+
* @param routeName - The name of the route, must be low cardinality
152+
* @returns The span name and attributes for the HTTP operation
153+
*/
154+
export function getHttpSpanDetailsFromUrlObject(
155+
urlObject: URLObject | undefined,
156+
kind: 'server' | 'client',
157+
spanOrigin: string,
158+
request?: PartialRequest,
159+
routeName?: string,
160+
): [name: string, attributes: SpanAttributes] {
161+
const attributes: SpanAttributes = {
162+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: spanOrigin,
163+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
164+
};
165+
166+
if (routeName) {
167+
// This is based on https://opentelemetry.io/docs/specs/semconv/http/http-spans/#name
168+
attributes[kind === 'server' ? 'http.route' : 'url.template'] = routeName;
169+
attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route';
170+
}
171+
172+
if (request?.method) {
173+
attributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] = request.method.toUpperCase();
174+
}
175+
176+
if (urlObject) {
177+
if (urlObject.search) {
178+
attributes['url.query'] = urlObject.search;
179+
}
180+
if (urlObject.hash) {
181+
attributes['url.fragment'] = urlObject.hash;
182+
}
183+
if (urlObject.pathname) {
184+
attributes['url.path'] = urlObject.pathname;
185+
if (urlObject.pathname === '/') {
186+
attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route';
187+
}
188+
}
189+
190+
if (!isURLObjectRelative(urlObject)) {
191+
attributes[SEMANTIC_ATTRIBUTE_URL_FULL] = urlObject.href;
192+
if (urlObject.port) {
193+
attributes['url.port'] = urlObject.port;
194+
}
195+
if (urlObject.protocol) {
196+
attributes['url.scheme'] = urlObject.protocol;
197+
}
198+
if (urlObject.hostname) {
199+
attributes[kind === 'server' ? 'server.address' : 'url.domain'] = urlObject.hostname;
200+
}
201+
}
202+
}
203+
204+
return [getHttpSpanNameFromUrlObject(urlObject, kind, request, routeName), attributes];
205+
}
206+
110207
/**
111208
* Parses string form of URL into an object
112209
* // borrowed from https://tools.ietf.org/html/rfc3986#appendix-B

0 commit comments

Comments
 (0)