Skip to content

Commit da5af89

Browse files
authored
feat: add http.route and the OTel url.* attribute set (#92)
* feat: add http.route and the OTel url.* attribute set Mirrors flare.entry_point.handler.identifier into http.route, and splits url.full into url.scheme, url.path and url.query through one shared core helper so redaction is applied once and the parts can never disagree. * task: simplify the comments added in this branch
1 parent 70b9b69 commit da5af89

25 files changed

Lines changed: 283 additions & 53 deletions

e2e/specs/react.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,11 @@ test.describe('react playground', () => {
4747
expect(pageload && attr(pageload, 'flare.entry_point.handler.identifier')).toEqual({
4848
stringValue: '/product/$id',
4949
});
50+
expect(pageload && attr(pageload, 'http.route')).toEqual({ stringValue: '/product/$id' });
5051
expect(pageload && attr(pageload, 'flare.route.source')).toEqual({ stringValue: 'route' });
52+
// http.route is the route template, url.path is the path the user actually hit.
53+
expect(pageload && attr(pageload, 'url.path')).toEqual({ stringValue: '/product/p01' });
54+
expect(pageload && attr(pageload, 'url.scheme')).toEqual({ stringValue: 'http' });
5155
});
5256

5357
test('navigation root carries the parameterized route (not the concrete path)', async ({ page, fakeFlare }) => {

packages/core/src/Flare.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,8 @@ export class Flare {
489489
const entryPointOverrides: Attributes = {};
490490
if (entryPoint?.identifier !== undefined) {
491491
entryPointOverrides['flare.entry_point.handler.identifier'] = entryPoint.identifier;
492+
// The OTel name for the same value. Set both here so they cannot drift apart.
493+
entryPointOverrides['http.route'] = entryPoint.identifier;
492494
}
493495
if (entryPoint?.type !== undefined) {
494496
entryPointOverrides['flare.entry_point.handler.type'] = entryPoint.type;

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export {
4848
safeClone,
4949
safeDecode,
5050
toCustomContext,
51+
urlAttributes,
5152
} from './util';
5253
export type { RejectionReporter, SafeCloneOptions, SdkTaggable } from './util';
5354

packages/core/src/util/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ export * from './rejection';
1212
export * from './safeClone';
1313
export * from './statelessRegExp';
1414
export * from './toCustomContext';
15+
export * from './urlAttributes';
1516
// utf8Bytes is deliberately not re-exported: an internal envelope-sizing helper, not a public seam.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Attributes } from '../types';
2+
import { DEFAULT_URL_DENYLIST, redactUrlQuery } from './redactUrl';
3+
4+
/**
5+
* Builds the OTel `url.*` attributes for one absolute URL.
6+
*
7+
* Redacts the URL first and splits it after, so `url.full` and `url.query` always show the same
8+
* redacted values.
9+
*
10+
* Leaves out `url.query` when there is no query string. Returns only `url.full` when the URL cannot
11+
* be parsed, for example a relative one.
12+
*/
13+
export function urlAttributes(url: string, denylist: RegExp = DEFAULT_URL_DENYLIST): Attributes {
14+
const full = redactUrlQuery(url, denylist);
15+
const attributes: Attributes = { 'url.full': full };
16+
17+
let parsed: URL;
18+
try {
19+
parsed = new URL(full);
20+
} catch {
21+
return attributes;
22+
}
23+
24+
// `protocol` ends with a colon, url.scheme does not.
25+
attributes['url.scheme'] = parsed.protocol.slice(0, -1);
26+
attributes['url.path'] = parsed.pathname;
27+
28+
if (parsed.search) {
29+
attributes['url.query'] = parsed.search.slice(1);
30+
}
31+
32+
return attributes;
33+
}

packages/core/tests/setEntryPoint.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ function browserCollector(_config: Readonly<Config>): Attributes {
1515
const attrs: Attributes = { 'flare.entry_point.type': 'web' };
1616
if (typeof window !== 'undefined' && window?.location?.pathname) {
1717
attrs['flare.entry_point.handler.identifier'] = window.location.pathname;
18+
// The real browser collector also sets http.route. Copy that here so the tests below can
19+
// check both keys move together.
20+
attrs['http.route'] = window.location.pathname;
1821
attrs['flare.entry_point.handler.type'] = 'browser';
1922
}
2023
return attrs;
@@ -40,6 +43,7 @@ test('default entry point handler is pathname + browser', async () => {
4043

4144
const a = fakeApi.lastReport!.attributes;
4245
expect(a['flare.entry_point.handler.identifier']).toBe('/users/42');
46+
expect(a['http.route']).toBe('/users/42');
4347
expect(a['flare.entry_point.handler.type']).toBe('browser');
4448
expect(a['flare.entry_point.handler.name']).toBeUndefined();
4549
});
@@ -51,6 +55,8 @@ test('setEntryPoint overrides identifier, type, and name', async () => {
5155

5256
const a = fakeApi.lastReport!.attributes;
5357
expect(a['flare.entry_point.handler.identifier']).toBe('/users/:id');
58+
// http.route copies the identifier, so the override has to win here too.
59+
expect(a['http.route']).toBe('/users/:id');
5460
expect(a['flare.entry_point.handler.type']).toBe('vue_route');
5561
expect(a['flare.entry_point.handler.name']).toBe('UserShow');
5662
});
@@ -74,6 +80,7 @@ test('setEntryPoint with only name falls back to collector defaults for identifi
7480
const a = fakeApi.lastReport!.attributes;
7581
// identifier falls back to default pathname from browser collector
7682
expect(a['flare.entry_point.handler.identifier']).toBe('/users/42');
83+
expect(a['http.route']).toBe('/users/42');
7784
// type falls back to default 'browser' from browser collector
7885
expect(a['flare.entry_point.handler.type']).toBe('browser');
7986
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { urlAttributes } from '../src/util/urlAttributes';
4+
5+
describe('urlAttributes', () => {
6+
it('splits an absolute url into the OTel url.* set', () => {
7+
expect(urlAttributes('https://shop.test/product/p01?size=l&color=red')).toEqual({
8+
'url.full': 'https://shop.test/product/p01?size=l&color=red',
9+
'url.scheme': 'https',
10+
'url.path': '/product/p01',
11+
'url.query': 'size=l&color=red',
12+
});
13+
});
14+
15+
it('leaves url.query out when there is no query string', () => {
16+
const attrs = urlAttributes('https://shop.test/cart');
17+
18+
expect(attrs).toEqual({
19+
'url.full': 'https://shop.test/cart',
20+
'url.scheme': 'https',
21+
'url.path': '/cart',
22+
});
23+
expect('url.query' in attrs).toBe(false);
24+
});
25+
26+
it('leaves url.query out for a bare question mark', () => {
27+
expect('url.query' in urlAttributes('https://shop.test/cart?')).toBe(false);
28+
});
29+
30+
it('redacts denylisted query values in both url.full and url.query', () => {
31+
const attrs = urlAttributes('https://shop.test/reset?token=abc123&name=dries');
32+
33+
expect(attrs['url.full']).toBe('https://shop.test/reset?token=[redacted]&name=dries');
34+
expect(attrs['url.query']).toBe('token=[redacted]&name=dries');
35+
});
36+
37+
it('honours a custom denylist', () => {
38+
const attrs = urlAttributes('https://shop.test/x?order_id=9&token=abc', /order_id/i);
39+
40+
expect(attrs['url.query']).toBe('order_id=[redacted]&token=abc');
41+
});
42+
43+
it('strips userinfo from url.full', () => {
44+
const attrs = urlAttributes('https://dries:hunter2@shop.test/account');
45+
46+
expect(attrs['url.full']).toBe('https://shop.test/account');
47+
expect(attrs['url.path']).toBe('/account');
48+
});
49+
50+
it('leaves path segments alone even when they look denylisted', () => {
51+
expect(urlAttributes('https://shop.test/token/abc123')['url.path']).toBe('/token/abc123');
52+
});
53+
54+
it('keeps the fragment on url.full but out of path and query', () => {
55+
const attrs = urlAttributes('https://shop.test/faq?q=1#shipping');
56+
57+
expect(attrs['url.full']).toBe('https://shop.test/faq?q=1#shipping');
58+
expect(attrs['url.path']).toBe('/faq');
59+
expect(attrs['url.query']).toBe('q=1');
60+
});
61+
62+
it('returns url.full alone for a url that will not parse', () => {
63+
expect(urlAttributes('/relative/path?token=abc')).toEqual({
64+
'url.full': '/relative/path?token=[redacted]',
65+
});
66+
});
67+
68+
it('reports non-http schemes', () => {
69+
const attrs = urlAttributes('file:///Users/dries/app/index.html');
70+
71+
expect(attrs['url.scheme']).toBe('file');
72+
expect(attrs['url.path']).toBe('/Users/dries/app/index.html');
73+
});
74+
});

packages/js/src/browser/context/collectBrowser.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { redactUrlQuery } from '@flareapp/core';
33

44
import cookie from './cookie';
55
import request from './request';
6-
import requestData from './requestData';
76

87
export function browserEntryPoint(config: Readonly<Config>, urlOverride?: URL): Attributes {
98
if (typeof window === 'undefined') {
@@ -22,6 +21,7 @@ export function browserEntryPoint(config: Readonly<Config>, urlOverride?: URL):
2221
const pathname = urlOverride ? urlOverride.pathname : window?.location?.pathname;
2322
if (pathname) {
2423
attrs['flare.entry_point.handler.identifier'] = pathname;
24+
attrs['http.route'] = pathname;
2525
attrs['flare.entry_point.handler.type'] = 'browser';
2626
}
2727
}
@@ -33,7 +33,7 @@ export const collectBrowser: ContextCollector = (config: Readonly<Config>): Attr
3333
const attrs: Attributes = { ...browserEntryPoint(config) };
3434

3535
// No window (SSR/node): browserEntryPoint already returned the entry point type on its own.
36-
// request()/requestData()/cookie() below touch window unguarded, so stop here.
36+
// request()/cookie() below touch window unguarded, so stop here.
3737
if (typeof window === 'undefined') {
3838
return attrs;
3939
}
@@ -46,7 +46,6 @@ export const collectBrowser: ContextCollector = (config: Readonly<Config>): Attr
4646
}
4747

4848
Object.assign(attrs, request(config.urlDenylist));
49-
Object.assign(attrs, requestData(config.urlDenylist));
5049
Object.assign(attrs, cookie(config.urlDenylist));
5150

5251
return attrs;

packages/js/src/browser/context/collectBrowserSpanContext.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Attributes, Config } from '@flareapp/core';
2-
import { redactUrlQuery } from '@flareapp/core';
2+
import { urlAttributes } from '@flareapp/core';
33

44
import { absoluteUrl } from '../../tracing/absoluteHref';
55
import { browserEntryPoint } from './collectBrowser';
@@ -23,11 +23,14 @@ export function collectBrowserSpanContext(config: Readonly<Config>, hrefOverride
2323
}
2424

2525
/**
26-
* Re-stamps a root's url after a redirect, or when a newer navigation replaces this one: the root opened
27-
* with the first destination, so it would otherwise report a page the user never landed on.
26+
* Updates a root's url after a redirect, or when a newer navigation replaces this one. The root opened
27+
* with the first destination, so without this it reports a page the user never reached.
2828
*
29-
* Leaves `flare.entry_point.handler.identifier` alone. The route template owns that, and deriving it from
30-
* the href would turn `/product/[id]` back into `/product/p01`.
29+
* Does not touch `flare.entry_point.handler.identifier` or `http.route`. Those hold the route template,
30+
* and reading them back from the href would turn `/product/[id]` into `/product/p01`.
31+
*
32+
* Always sets `url.query`, even to an empty string. You can overwrite a span attribute but not remove
33+
* it, so going from `/a?x=1` to `/b` would otherwise keep the old query.
3134
*/
3235
export function browserSpanUrlAttributes(config: Readonly<Config>, href: string): Attributes {
3336
if (typeof window === 'undefined') {
@@ -37,6 +40,6 @@ export function browserSpanUrlAttributes(config: Readonly<Config>, href: string)
3740
if (!resolved) {
3841
return {};
3942
}
40-
const redacted = redactUrlQuery(resolved.href, config.urlDenylist);
41-
return { 'url.full': redacted, 'flare.entry_point.value': redacted };
43+
const attributes = urlAttributes(resolved.href, config.urlDenylist);
44+
return { 'url.query': '', ...attributes, 'flare.entry_point.value': attributes['url.full'] };
4245
}

packages/js/src/browser/context/request.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import type { Attributes } from '@flareapp/core';
2-
import { redactUrlQuery } from '@flareapp/core';
2+
import { redactUrlQuery, urlAttributes } from '@flareapp/core';
33

44
/**
5-
* @param hrefOverride when set, `url.full` is derived from it instead of the live
5+
* @param hrefOverride when set, the `url.*` attributes come from it instead of the live
66
* `window.location.href` (a framework navigation root whose router knows the destination
77
* before the URL commits). The override is pre-validated by the caller.
88
*/
99
export default function request(urlDenylist: RegExp, hrefOverride?: string): Attributes {
1010
return {
11-
'url.full': redactUrlQuery(hrefOverride ?? window.location.href, urlDenylist),
11+
...urlAttributes(hrefOverride ?? window.location.href, urlDenylist),
1212
'user_agent.original': window.navigator.userAgent,
1313
'http.request.referrer': redactUrlQuery(window.document.referrer, urlDenylist),
1414
'document.ready_state': window.document.readyState,

0 commit comments

Comments
 (0)