- name
- port-span-names
- description
- Port a span op to low-cardinality span names, applied only when span streaming is enabled. Use when asked to make a span op's names low cardinality, remove raw URLs/IDs from span names, apply the Sentry span name conventions to an op, or "port <op> spans to low cardinality names". Trigger phrases include "low cardinality span name", "span name conventions", "span name fallback", "Pageload/Navigation span name".
- argument-hint
- <span-op> [constraints] # e.g. navigation, or "db.query --only packages/node"
# Port a span op to low-cardinality span names
Span names must be low cardinality, per the [Sentry span name conventions](https://getsentry.github.io/sentry-conventions/names/): a raw URL, id, or query must never end up in a span name. Where the SDK has no low-cardinality value for a span, it uses a **static fallback name** for that op instead.
This only applies when span streaming is enabled. With `traceLifecycle: 'static'` every span name must stay byte-identical to before.
`pageload` was ported first. Read it as the reference implementation before starting:
`packages/core/src/tracing/spans/spanNames.ts` (the constant), `packages/browser/src/tracing/browserTracingIntegration.ts`
(a start site plus the scope guard in `startBrowserTracingPageLoadSpan`), and
`grep -rn PAGELOAD_SPAN_NAME_FALLBACK packages/*/src` for the full set of call sites.
## Inputs
- **`<span-op>`** (required): the op to port, e.g. `navigation`, `db.query`, `http.client`.
- **`[constraints]`** (optional): free-form narrowing, e.g. `--only packages/node`, `just the vue router instrumentation`, `skip tanstack`. Honor them literally: touch nothing outside the named scope, and say in your summary which sites you deliberately left for a follow-up.
If the op is missing, ask for it. Do not guess.
## Rules
These are non-negotiable. Every one of them was arrived at by rejecting the alternative.
1. **Gate on span streaming at each site.** `hasSpanStreamingEnabled(client)` (from `@sentry/core`) must appear inline where the name is chosen, so a reader can see the gate without following a call chain. Never gate centrally.
2. **Set the low-cardinality name when the span _starts_** — and at every later site that could write a high-cardinality name onto it. Never rewrite names retroactively (not in `captureSpan`, not in `spanToJSON`, not in a `processSpan`/`preprocessSpan` hook). A span must never carry a raw URL.
3. **Check span name updates** to ensure every name update is low-cardinality if span streaming is enabled.
4. **Only the name and its description change.** Do not touch `sentry.source`, `url.template`, `http.route`, or any other attribute — they keep describing where the name came from. The two exceptions are `sentry.description` and the single attribute the op's description template reads (see step 1); both exist to preserve what the name gave up.
5. **Do not derive the name from attributes in code.** The conventions describe names as attribute templates, but you implement them by reusing the value the site _already_ has for `url.template` / `http.route`. No attribute lookups, no generic template resolver.
6. **No helpers, no abstraction.** An inline ternary at each site. A shared `const` for the fallback string is fine (and required, see rule 6); a function that sets names or attributes is not.
7. **The fallback must never reach `scope.setTransactionName`.** The scope's transaction name is what error events are grouped by, so it keeps the raw URL or the parameterized route — never `Pageload`/`Navigation`/etc. Export the fallback as a constant from `packages/core/src/tracing/spans/spanNames.ts` so the guard cannot drift.
8. **`sentry.segment.name` must never diverge from the segment span's name.** Any code that stamps it on a child span has to read it off the segment span, not off the scope.
## 1. Look up the convention
Read <https://getsentry.github.io/sentry-conventions/names/> and find the op. Each op lists attribute templates in priority order, ending in a static fallback — that fallback is your name. Examples: `pageload` → `Pageload`, `navigation` → `Navigation`, database ops → `Database operation`.
Add it next to `PAGELOAD_SPAN_NAME_FALLBACK` in `packages/core/src/tracing/spans/spanNames.ts` and export it from `shared-exports.ts`. Every package imports it from `@sentry/core` directly — no re-export from `@sentry/browser` is needed.
### Then look up the description rules
The name is only half the contract. Relay re-derives a span's **description** from its attributes using the [span description rules](https://getsentry.github.io/sentry-conventions/descriptions/) — a separate, much shorter set of templates from the name rules. `function`, for example, has exactly one (`{{code.function.name}}`) and no static fallback, so a `function` span with no `code.function.name` gets no inferred description at all.
Renaming a span therefore drops information unless you check what the op's description template can still produce. For each site, in this order:
1. **The template rebuilds the old name from attributes the site already sets** — do nothing, Relay infers it.
2. **It rebuilds it from an attribute the site could legitimately set** — set that attribute. Only when the value genuinely describes the span, never to smuggle the old name into an attribute that means something else.
3. **It cannot** — set `sentry.description` (`SENTRY_DESCRIPTION` from `@sentry/conventions/attributes`) to the name the span had before.
Case 3 must only be applied if case 1 or 2 do not work for the span: the templates are generic, and most old names were framework-specific strings the conventions cannot express (`serverAction/updateUser`, `Scheduled Cron */5 * * * *`, `Fetcher fetcher-1`).
```ts
// Relay infers a `function` span's description from `code.function.name` alone, which drops the route.
...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: routePattern }),
```
Two constraints on the description:
- **Set it on the streaming branch only**, gated by the same `hasSpanStreamingEnabled` check as the name. In transaction mode the description already equals the name, and writing it changes nothing.
- **Descriptions may be high cardinality** — the `http.server` templates end in `url.full`. The raw URL, route or id that the name gave up belongs here. This is the one place the cardinality rule does not apply.
## 2. Find every site that names a span with this op
Be exhaustive; a missed site is a raw URL in production.
Most sites set the op to the constant from `@sentry/conventions/op` (`FUNCTION`, `HTTP_SERVER`, ...), not to a string literal, so grep for both spellings:
```bash
# span starts, via the op constant
grep -rn "SENTRY_OP\]: <OP_CONST>\|SEMANTIC_ATTRIBUTE_SENTRY_OP\]: <OP_CONST>\|op: <OP_CONST>\|spanOp: <OP_CONST>\|\.op = <OP_CONST>" packages/*/src
# span starts, via a string literal
grep -rn "SENTRY_OP\]: '<op>'\|SEMANTIC_ATTRIBUTE_SENTRY_OP\]: '<op>'\|op: '<op>'\|= '<op>'" packages/*/src
# for browser routing ops, also the dedicated starters
grep -rn "startBrowserTracingNavigationSpan\|startBrowserTracingPageLoadSpan" packages/*/src
# later name writes
grep -rn "\.updateName(\|updateSpanName(" packages/*/src
# readers that compare a span name against a URL or route (these break, see step 4)
grep -rn "spanToJSON(.*)\.name" packages/*/src
```
Neither grep finds an op that reaches the span through a variable, and those are the sites that get missed. Enumerate them by hand — list every file importing the op constant (`grep -rln "<OP_CONST>" packages/*/src`) and follow the ones that never spell out a span start:
- **shared wrappers** taking the op as an option — `spanOp: FUNCTION` handed to cloudflare's `wrapMethodWithSentry`
- **helpers computing the op** — `getGenAiSpanOp(operation)` returns `function` for any unrecognized gen-AI operation
- **defaults on public APIs** — `SentryTraced(op: string = 'function')` in `@sentry/nestjs`
Cross-check the total against the renames too: a `updateSpanName` in a different file can rewrite a span whose op is set elsewhere (`tanstackstart-react`'s global function middleware renames the span that `wrapFetchWithSentry` starts), and it will not appear in any op grep.
Classify each write site: does it set a low cardinality value? Low cardinality values are for example:
- a parameterized route (i.e. one without dynamic parameters)
- an http origin without url path, query or fragment parameters
- a component name
- a static string
In cases, where we already set a low-cardinality value, likely only the fallback branch changes. For example, when a route is known the name is identical in both lifecycles. But when it's unknown, transaction mode falls back to the raw URL. Span streaming mode falls back to a static "Pageload" string.
Note: For a lot of route/URL cases, the `sentry.source` attribute having the value `"url"` strongly hints that the span name is high-cardinality. Use this as a strong indicator but not as definitive proof. Sometimes this attribute is incorrectly set. If you find such a case, flag it rather than following the rule.
## 3. Apply the fallback
The whole change per site is the else-branch of a ternary:
```ts
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
name: parameterizedRoute ?? (hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : pathname),
```
Where a site computes `[name, source]` together, gate on the source (but see the note above!):
```ts
name: source === 'route' || !hasSpanStreamingEnabled(client) ? name : NAVIGATION_SPAN_NAME_FALLBACK,
```
At update sites, keep the surrounding `setAttribute` calls exactly as they were:
```ts
const client = getClient();
const isUnparameterizedStreamedSpan = source !== 'route' && !!client && hasSpanStreamingEnabled(client);
span.updateName(isUnparameterizedStreamedSpan ? NAVIGATION_SPAN_NAME_FALLBACK : name);
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); // unchanged
```
`hasSpanStreamingEnabled` takes a non-optional `Client`, so guard `getClient()` with `!!client` where no client is in scope.
## 4. Fix readers, never work around them
Some instrumentation reads a span's name back and compares it to a pathname or route to decide whether an event belongs to that span. Those silently stop matching once the name is a fallback — the span then never gets parameterized at all, with no error.
Re-point them at the attribute that holds the real value (`url.path`, `url.full`, `url.template`). Reading an attribute here is correct; only _writing_ attributes is out of scope.
```ts
// Matched against `url.path` rather than the span name, which is low cardinality.
const spanPath = spanToJSON(span).attributes[URL_PATH];
```
## 5. Guard the scope transaction name
Find every `setTransactionName` reachable from a span you renamed:
```bash
grep -rn "setTransactionName" packages/*/src
```
Most calls pass a route and are fine. The dangerous ones pass a span name through. Guard those:
```ts
// `Navigation` is a low-cardinality span name, not a description of the page. The scope's
// transaction name is what error events are grouped by, so it keeps the URL instead.
const isFallbackSpanName = spanOptions.name === NAVIGATION_SPAN_NAME_FALLBACK;
getCurrentScope().setTransactionName(isFallbackSpanName ? WINDOW.location?.pathname : spanOptions.name);
```
Pin this with a test asserting the span name is the fallback **and** `scope.transactionName` is not.
## 6. Keep `sentry.segment.name` consistent
`captureSpan` derives `sentry.segment.name` from the real segment span, but any code that pre-sets the attribute wins over it (`safeSetSpanJSONAttributes` does not overwrite). If a child-span emitter copies the name from the scope, it will diverge as soon as the segment span is renamed. Read it off the segment span instead:
```ts
const segmentSpan = parentSpan && getRootSpan(parentSpan);
const segmentName = segmentSpan ? spanToJSON(segmentSpan).name : getCurrentScope().getScopeData().transactionName;
```
The scope is only a valid fallback for standalone spans, which are sent without their segment span. `packages/browser-utils/src/web-vitals/spans.ts` is the worked example.
## 7. Verify
**Build first.** Framework packages and the Playwright suites resolve `@sentry/core` from `build/`, not `src/`. A stale build produces failures that look like your bug but aren't.
```bash
yarn build:dev:filter @sentry/browser # plus each package you touched
npx vitest run --root packages/<pkg> --coverage.enabled=false
cd dev-packages/browser-integration-tests && npx playwright test --project=chromium suites/tracing
```
Before assuming a failure is yours, baseline it: `git stash`, re-run, `git stash pop`. Commonly pre-existing in a dev checkout: `packages/core` `test/types/typedef.test.ts` (needs a full prod build), the `@sentry/ember` build, and `packages/nuxt` lint errors.
Expect to update, and read each one to confirm the new value is _correct_ rather than just green:
- unit assertions on the span name and on `scope.transactionName`
- the span's `sentry.description` in **both** lifecycles: streamed spans carry the previous name, static ones must not gain the attribute
- `dev-packages/browser-integration-tests` suites whose `init.js` does **not** set `traceLifecycle: 'static'` (the static ones must not change — that is your regression check)
- `sentry.segment.name` / `sentry.transaction` on child spans of a renamed segment span
- span mocks missing `spanContext`/`setAttribute` once code paths shift
Finish with `yarn format` and `yarn lint`.
## 8. Document it in MIGRATION.md
Extend the existing **"Span name changes"** section under `## 2. Behaviour Changes` — add a row to its table rather than starting a new section:
| Span op | Before | After |
| ------- | ------------------------------------------ | ---------------------------------------------- |
| `<op>` | what the name was, with a concrete example | the route, or `<Fallback>` if the SDK has none |
Also note, if they apply: that `ignoreSpans` is evaluated at span **start** (so filters matching a URL no longer match a fallback-named span, and users should match on attributes instead), any child-span attribute that follows the new name, and any span of a _different_ op that inherits the name (e.g. `ui.action.click` spans are named after the current route).
## Rejected approaches
Do not propose these; they were each tried and rejected in the `pageload` port.
- **Rewriting names at capture/serialization time.** Correct output, but the span carries a raw URL for its whole lifetime, and anything reading the name mid-flight sees it.
- **A `updateRouteSpanName(span, name, source, attributes)` helper.** It set attributes as a side effect, which is out of scope, and hid the streaming check.
- **Deriving the name from `url.template` via a generic convention resolver.** Over-abstracted; the call site already has the value.
- **Tracking a second `segmentName` on the scope.** Looks like it fixes `sentry.segment.name`, but it diverges the moment a routing instrumentation renames the span — unless every rename site also updates the scope, which is the same duplicated-truth bug moved elsewhere.
Ver en GitHub