Skip to content

Commit 2e15d04

Browse files
chargomeclaude
andauthored
ref(remix)!: Align action form-data span attribute with conventions (#23227)
Node emitted `formData.<key>` for Remix action form data while Cloudflare/Hydrogen emitted `remix.action_form_data.<key>`. `@sentry/conventions` settles on the latter, so the Node name moves. closes #23007 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent cba2ba5 commit 2e15d04

8 files changed

Lines changed: 30 additions & 26 deletions

File tree

dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ test('Sends form data with action span', async ({ page }) => {
4141
expect(actionSpan?.op).toBe('function');
4242
expect(actionSpan?.data?.['code.function.name']).toBe('action');
4343
expect(actionSpan?.data).toMatchObject({
44-
'formData.text': 'test',
45-
'formData.file': 'file.txt',
44+
'remix.action_form_data.text': 'test',
45+
'remix.action_form_data.file': 'file.txt',
4646
});
4747
});
4848

docs/migration/v11-end-state.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,11 @@ If `captureActionFormDataKeys` is not set, all form fields are captured when
286286
looks sensitive (`password`, `token`, …) are replaced with `[Filtered]`, including explicitly
287287
allowlisted ones.
288288

289+
The captured fields are now reported as `remix.action_form_data.<field>` span attributes on every
290+
runtime. On Node, they were previously reported as `formData.<field>`; the Cloudflare and Hydrogen
291+
paths already used the new name. Update any dashboards, alerts, or saved searches that query
292+
`formData.*`.
293+
289294
### Channel-based instrumentation is the default
290295

291296
Affected SDKs: `@sentry/node` and all dependents.

packages/remix/src/server/integrations/tracing-channel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ function subscribeCallRouteAction(formDataCapture: FormDataCapture | undefined):
240240
}
241241

242242
formData
243-
.then(resolved => applyFormDataAttributes(span, resolved, formDataCapture, 'formData.'))
243+
.then(resolved => applyFormDataAttributes(span, resolved, formDataCapture))
244244
// Silently continue on any error. Typically happens because the action body cannot be
245245
// processed into FormData, in which case we should just continue.
246246
.catch(() => undefined)

packages/remix/src/utils/formData.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { REMIX_ACTION_FORM_DATA_KEY_BASE } from '@sentry/conventions/attributes';
12
import type { Client, Span } from '@sentry/core';
23
import { _INTERNAL_filterKeyValueData } from '@sentry/core';
34
import type { RemixOptions } from './remixOptions';
@@ -30,16 +31,11 @@ export function resolveFormDataCapture(client: Client | undefined): FormDataCapt
3031
}
3132

3233
/**
33-
* Sets form-data span attributes under `attributePrefix`, honoring the configured key list and
34-
* renames. Values are run through the shared sensitive-key filter, so an allowlisted `password`
35-
* still reports as `[Filtered]` rather than in the clear.
34+
* Sets form-data span attributes, honoring the configured key list and renames. Values are run
35+
* through the shared sensitive-key filter, so an allowlisted `password` still reports as
36+
* `[Filtered]` rather than in the clear.
3637
*/
37-
export function applyFormDataAttributes(
38-
span: Span,
39-
formData: FormData,
40-
{ keys }: FormDataCapture,
41-
attributePrefix: string,
42-
): void {
38+
export function applyFormDataAttributes(span: Span, formData: FormData, { keys }: FormDataCapture): void {
4339
const collected: Record<string, string> = {};
4440

4541
formData.forEach((value, key) => {
@@ -59,6 +55,6 @@ export function applyFormDataAttributes(
5955
});
6056

6157
for (const [key, value] of Object.entries(collected)) {
62-
span.setAttribute(`${attributePrefix}${key}`, value);
58+
span.setAttribute(`${REMIX_ACTION_FORM_DATA_KEY_BASE}.${key}`, value);
6359
}
6460
}

packages/remix/src/utils/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export async function storeFormDataKeys(
2626
// https://remix.run/docs/en/main/utils/parse-multipart-form-data#unstable_parsemultipartformdata
2727
const formData = await clonedRequest.formData();
2828

29-
applyFormDataAttributes(span, formData, formDataCapture, 'remix.action_form_data.');
29+
applyFormDataAttributes(span, formData, formDataCapture);
3030
} catch (e) {
3131
DEBUG_BUILD && debug.warn('Failed to read FormData from request', e);
3232
}

packages/remix/test/server/tracing-channel-no-form-data.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ describe('remixIntegration with orchestrion (no form-data capture configured)',
6161
}),
6262
);
6363
expect(span.setAttribute).toHaveBeenCalledWith('http.status_code', 201);
64-
// No form-data capture configured, so no `formData.*` attribute is set.
65-
expect(span.setAttribute).not.toHaveBeenCalledWith('formData.actionType', expect.anything());
64+
// No form-data capture configured, so no `remix.action_form_data.*` attribute is set.
65+
expect(span.setAttribute).not.toHaveBeenCalledWith('remix.action_form_data.actionType', expect.anything());
6666
expect(span.end).toHaveBeenCalledTimes(1);
6767
});
6868
});

packages/remix/test/server/tracing-channel.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,6 @@ describe('remixIntegration (Orchestrion-based)', () => {
150150
// The span ends only after the async form-data read resolves.
151151
await vi.waitFor(() => expect(span.end).toHaveBeenCalledTimes(1));
152152
expect(span.setAttribute).toHaveBeenCalledWith('http.status_code', 201);
153-
expect(span.setAttribute).toHaveBeenCalledWith('formData.actionType', 'create');
153+
expect(span.setAttribute).toHaveBeenCalledWith('remix.action_form_data.actionType', 'create');
154154
});
155155
});

packages/remix/test/utils/formData.test.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ function applyTo(formData: FormData, keys: Record<string, string | boolean> | un
2424
const attributes: Record<string, unknown> = {};
2525
const span = { setAttribute: (key: string, value: unknown) => void (attributes[key] = value) } as unknown as Span;
2626

27-
applyFormDataAttributes(span, formData, { keys }, 'formData.');
27+
applyFormDataAttributes(span, formData, { keys });
2828

2929
return attributes;
3030
}
@@ -57,31 +57,34 @@ describe('applyFormDataAttributes', () => {
5757
it('sets only allowlisted fields', () => {
5858
const attributes = applyTo(formDataOf({ username: 'alice', bio: 'ignored' }), { username: true });
5959

60-
expect(attributes).toEqual({ 'formData.username': 'alice' });
60+
expect(attributes).toEqual({ 'remix.action_form_data.username': 'alice' });
6161
});
6262

6363
it('applies renames', () => {
6464
const attributes = applyTo(formDataOf({ username: 'alice' }), { username: 'user' });
6565

66-
expect(attributes).toEqual({ 'formData.user': 'alice' });
66+
expect(attributes).toEqual({ 'remix.action_form_data.user': 'alice' });
6767
});
6868

6969
it('sets every field when no keys are configured', () => {
7070
const attributes = applyTo(formDataOf({ username: 'alice', bio: 'hello' }), undefined);
7171

72-
expect(attributes).toEqual({ 'formData.username': 'alice', 'formData.bio': 'hello' });
72+
expect(attributes).toEqual({ 'remix.action_form_data.username': 'alice', 'remix.action_form_data.bio': 'hello' });
7373
});
7474

7575
it('filters sensitive values when capturing all fields', () => {
7676
const attributes = applyTo(formDataOf({ username: 'alice', password: 'hunter2' }), undefined);
7777

78-
expect(attributes).toEqual({ 'formData.username': 'alice', 'formData.password': '[Filtered]' });
78+
expect(attributes).toEqual({
79+
'remix.action_form_data.username': 'alice',
80+
'remix.action_form_data.password': '[Filtered]',
81+
});
7982
});
8083

8184
it('filters sensitive values even when explicitly allowlisted', () => {
8285
const attributes = applyTo(formDataOf({ password: 'hunter2' }), { password: true });
8386

84-
expect(attributes).toEqual({ 'formData.password': '[Filtered]' });
87+
expect(attributes).toEqual({ 'remix.action_form_data.password': '[Filtered]' });
8588
});
8689

8790
it('still filters a sensitive field renamed after rename', () => {
@@ -90,22 +93,22 @@ describe('applyFormDataAttributes', () => {
9093
// value ships in the clear.
9194
const attributes = applyTo(formDataOf({ password: 'hunter2' }), { password: 'pw' });
9295

93-
expect(attributes).toEqual({ 'formData.pw': '[Filtered]' });
96+
expect(attributes).toEqual({ 'remix.action_form_data.pw': '[Filtered]' });
9497
});
9598

9699
it('reports the filename for file uploads, not the contents', () => {
97100
const formData = new FormData();
98101
formData.append('avatar', new Blob(['file contents']), 'avatar.png');
99102

100-
expect(applyTo(formData, undefined)).toEqual({ 'formData.avatar': 'avatar.png' });
103+
expect(applyTo(formData, undefined)).toEqual({ 'remix.action_form_data.avatar': 'avatar.png' });
101104
});
102105

103106
it('reports a placeholder for unnamed non-string values', () => {
104107
const formData = new FormData();
105108
// An appended Blob with no filename reports as `blob` in undici; force the empty-name case.
106109
formData.append('avatar', new Blob(['x']), '');
107110

108-
expect(applyTo(formData, undefined)).toEqual({ 'formData.avatar': '[non-string value]' });
111+
expect(applyTo(formData, undefined)).toEqual({ 'remix.action_form_data.avatar': '[non-string value]' });
109112
});
110113

111114
it('sets nothing for an empty form', () => {

0 commit comments

Comments
 (0)