From b2fd93f9abb0bb5dde0bdd112dd574816c27669b Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sat, 10 Oct 2026 22:03:29 -0400 Subject: [PATCH 1/3] fix(datadog): withhold failed secrets-operation text, which quotes secret names A failed set/delete/grant/revoke secret rejected with a plain Error carrying Harper's response text, and React Query's global handler passes that to console.error, which the RUM SDK reports. Harper composes those messages by interpolating the secret and component names (harper-pro core/components/secretOperations.ts), so they reached Error Tracking as written (#1664). All three throw sites now throw a named SecretsOperationError, and that name joins RELAYED_ERROR_TYPES, so beforeSend withholds the message and the stack header the same way it does for SSEOperationError and ImportJobError. The class carries no cause: under #1665 a cause on a relayed error would withhold the whole event, losing the failure rate. The secrets dialogs and grants editor toasted String(error), which would now read "SecretsOperationError: ..."; they toast describeError's message instead, as the global handler does. The shared SecretModals also serve the env-var editor, whose toasts now show Harper's response text (or axios's own message when there is none) rather than "AxiosError: Request failed with status code ...". Closes #1664 Co-Authored-By: Claude Opus 5.5 --- .../config/secrets/SecretGrantsEditor.tsx | 5 ++- .../instance/secrets/SecretModals.test.tsx | 25 +++++++++++ .../instance/secrets/SecretModals.tsx | 7 +-- .../api/instance/secrets/secrets.test.tsx | 44 ++++++++++++++++--- .../api/instance/secrets/secrets.ts | 14 ++++-- .../datadog/beforeSend.sdk.test.ts | 19 +++++++- .../datadog/redactRelayedMessage.test.ts | 10 +++++ .../datadog/redactRelayedMessage.ts | 4 +- 8 files changed, 110 insertions(+), 18 deletions(-) diff --git a/src/features/instance/config/secrets/SecretGrantsEditor.tsx b/src/features/instance/config/secrets/SecretGrantsEditor.tsx index 4490e8274..9525adb40 100644 --- a/src/features/instance/config/secrets/SecretGrantsEditor.tsx +++ b/src/features/instance/config/secrets/SecretGrantsEditor.tsx @@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { ComponentGrantCombobox } from '@/features/instance/secrets/ComponentGrantCombobox'; import { useGrantSecret, useRevokeSecret } from '@/integrations/api/instance/secrets/secrets'; +import { describeError } from '@/react-query/queryClient'; import { XIcon } from 'lucide-react'; import { useCallback, useState } from 'react'; import { toast } from 'sonner'; @@ -40,7 +41,7 @@ export function SecretGrantsEditor({ setGrants(response.grants); onChanged?.(); } catch (error) { - toast.error(String(error)); + toast.error(describeError(error).message); // Rethrow so the combobox keeps the typed text for a retry instead of clearing it. throw error; } @@ -52,7 +53,7 @@ export function SecretGrantsEditor({ setGrants(response.grants); onChanged?.(); } catch (error) { - toast.error(String(error)); + toast.error(describeError(error).message); } }, [revokeSecret, instanceParams, name, onChanged]); diff --git a/src/features/instance/secrets/SecretModals.test.tsx b/src/features/instance/secrets/SecretModals.test.tsx index a86f12ee6..f70717e61 100644 --- a/src/features/instance/secrets/SecretModals.test.tsx +++ b/src/features/instance/secrets/SecretModals.test.tsx @@ -6,6 +6,8 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })); +import { SecretsOperationError } from '@/integrations/api/instance/secrets/secrets'; +import { toast } from 'sonner'; import { AddSecretModal, EditSecretModal } from './SecretModals'; beforeAll(() => { @@ -31,6 +33,29 @@ async function clearAndLeave(field: HTMLElement) { } describe('AddSecretModal', () => { + // A named error class is how Datadog knows to withhold the text; the toast must not show the name. + it("toasts a failed save's message without its error class", async () => { + const message = "Secret 'ACME_STRIPE_KEY' cannot be both processEnv and grant-scoped"; + render( + {}} + />, + ); + await act(async () => { + fireEvent.change(screen.getByRole('textbox', { name: /key/i }), { target: { value: 'ACME_STRIPE_KEY' } }); + fireEvent.change(screen.getByRole('textbox', { name: /value/i }), { target: { value: 'sk_live' } }); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /add secret/i })); + }); + + expect(toast.error).toHaveBeenCalledWith(message); + }); + it('asks for a key and a value once each is left empty', async () => { render( { }); const { result } = renderHook(() => useSetSecret(), { wrapper }); - await expect(result.current.mutateAsync({ ...params, name: 'API_KEY', value: 'x' })).rejects.toThrow( - 'secrets custody is not initialized on this node', - ); + await expect(result.current.mutateAsync({ ...params, name: 'API_KEY', value: 'x' })).rejects.toMatchObject({ + name: 'SecretsOperationError', + message: 'secrets custody is not initialized on this node', + }); }); it('fetches the key from central-manager for managed clusters (central-manager#409)', async () => { @@ -196,8 +203,31 @@ describe('useDeleteSecret', () => { expect(post).toHaveBeenCalledWith('/', { operation: 'delete_secret', name: 'API_KEY' }); post.mockRejectedValueOnce({ response: { data: { error: "No secret found with name 'NOPE'" } } }); - await expect(result.current.mutateAsync({ ...params, name: 'NOPE' })).rejects.toThrow( - "No secret found with name 'NOPE'", - ); + await expect(result.current.mutateAsync({ ...params, name: 'NOPE' })).rejects.toMatchObject({ + name: 'SecretsOperationError', + message: "No secret found with name 'NOPE'", + }); + }); +}); + +describe.each( + [ + ['useGrantSecret', useGrantSecret, 'grant_secret'], + ['useRevokeSecret', useRevokeSecret, 'revoke_secret'], + ] as const, +)('%s', (_hook, useHook, operation) => { + it(`posts ${operation} and surfaces the body error message on failure`, async () => { + const { post, wrapper, params } = harness(); + post.mockResolvedValueOnce({ data: { name: 'API_KEY', grants: ['app'], changed: true } }); + + const { result } = renderHook(() => useHook(), { wrapper }); + await result.current.mutateAsync({ ...params, name: 'API_KEY', component: 'app' }); + expect(post).toHaveBeenCalledWith('/', { operation, name: 'API_KEY', component: 'app' }); + + post.mockRejectedValueOnce({ response: { data: { error: "No secret found with name 'NOPE'" } } }); + await expect(result.current.mutateAsync({ ...params, name: 'NOPE', component: 'app' })).rejects.toMatchObject({ + name: 'SecretsOperationError', + message: "No secret found with name 'NOPE'", + }); }); }); diff --git a/src/integrations/api/instance/secrets/secrets.ts b/src/integrations/api/instance/secrets/secrets.ts index 9c00faf93..ae08093ef 100644 --- a/src/integrations/api/instance/secrets/secrets.ts +++ b/src/integrations/api/instance/secrets/secrets.ts @@ -54,6 +54,14 @@ export interface ListSecretsResponse { custody_fingerprint: string | null; } +/** A secrets operation Harper rejected. Datadog withholds its message by this `name`. */ +export class SecretsOperationError extends Error { + constructor(message: string) { + super(message); + this.name = 'SecretsOperationError'; + } +} + /** Operations API errors carry the message in the response body; surface that, not the Axios noise. */ function operationErrorMessage(error: unknown): string { const body = (error as { response?: { data?: { error?: string; message?: string } } })?.response?.data; @@ -171,7 +179,7 @@ async function encryptAndSetSecret(queryClient: QueryClient, args: SetSecretArgs queryClient.removeQueries({ queryKey: secretsPublicKeyQueryOptions(args).queryKey }); return encryptAndSetSecret(queryClient, args, true); } - throw new Error(message); + throw new SecretsOperationError(message); } } @@ -191,7 +199,7 @@ async function deleteSecret({ instanceClient, name }: DeleteSecretArgs): Promise const { data } = await instanceClient.post('/', { operation: 'delete_secret', name }); return data; } catch (error) { - throw new Error(operationErrorMessage(error)); + throw new SecretsOperationError(operationErrorMessage(error)); } } @@ -218,7 +226,7 @@ async function mutateGrant( const { data } = await instanceClient.post('/', { operation, name, component }); return data; } catch (error) { - throw new Error(operationErrorMessage(error)); + throw new SecretsOperationError(operationErrorMessage(error)); } } diff --git a/src/integrations/datadog/beforeSend.sdk.test.ts b/src/integrations/datadog/beforeSend.sdk.test.ts index bd17ec70b..42eae991b 100644 --- a/src/integrations/datadog/beforeSend.sdk.test.ts +++ b/src/integrations/datadog/beforeSend.sdk.test.ts @@ -1,5 +1,6 @@ import { createRequire } from 'node:module'; import { describe, expect, it } from 'vitest'; +import { SecretsOperationError } from '../api/instance/secrets/secrets'; import { beforeSend } from './beforeSend'; import { DatadogErrorEvent } from './shouldKeepEvent'; @@ -37,11 +38,16 @@ function sent(error: Error, hook: (event: SentEvent) => boolean) { } /** A stack the SDK parses into Studio-hosted frames, so only the text under test can need redaction. */ +function withStudioStack(error: T) { + error.stack = + `${error.name}: ${error.message}\n at fb (https://fabric.harper.fast/assets/index-Dcf_w9Ns.js:26:1557)`; + return error; +} + function thrown(name: string, message: string, cause?: Error) { const error = new Error(message, { cause }); error.name = name; - error.stack = `${name}: ${message}\n at fb (https://fabric.harper.fast/assets/index-Dcf_w9Ns.js:26:1557)`; - return error; + return withStudioStack(error); } const idle = 'The stream went idle before completing.'; @@ -73,4 +79,13 @@ describe('beforeSend in the RUM SDK pipeline', () => { expect(events[0].error.message).toBe('rewritten'); expect(events[0].error.causes?.[0].message).toBe('Failed to clone acme-corp/billing'); }); + + it('withholds a failed secrets operation by the type the SDK reads off the thrown class', () => { + const events = sent( + withStudioStack(new SecretsOperationError("No secret found with name 'ACME_STRIPE_KEY'")), + beforeSend, + ); + expect(events).toHaveLength(1); + expect(events[0].error.message).toBe('Harper reported an operation failure (server message withheld).'); + }); }); diff --git a/src/integrations/datadog/redactRelayedMessage.test.ts b/src/integrations/datadog/redactRelayedMessage.test.ts index da530c0aa..57e6e6e5c 100644 --- a/src/integrations/datadog/redactRelayedMessage.test.ts +++ b/src/integrations/datadog/redactRelayedMessage.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { DeployRolloutError } from '../api/instance/applications/restartJob'; import { ImportJobError } from '../api/instance/database/getJob'; +import { SecretsOperationError } from '../api/instance/secrets/secrets'; import { SSEOperationError } from '../api/sse/errors'; import { redactRelayedMessage, redactRelayedStack } from './redactRelayedMessage'; @@ -80,6 +81,15 @@ describe('redactRelayedMessage', () => { expect(redacted).toBe(WITHHELD + frames); }); + it("withholds a failed secrets operation, whose message quotes the secret's name", () => { + const error = new SecretsOperationError("No secret found with name 'ACME_STRIPE_KEY'"); + expect(error.name).toBe('SecretsOperationError'); + expect(redactRelayedMessage(error.name, error.message)).toBe(WITHHELD); + const frames = '\n at deleteSecret @ https://fabric.harper.fast/assets/index-A1b2C3d4.js:5:1234'; + expect(redactRelayedStack(error.name, error.message, `SecretsOperationError: ${error.message}${frames}`)) + .toBe(WITHHELD + frames); + }); + it('removes exactly the message and keeps what follows', () => { const message = [ 'SSEOperationError: Failed to clone package github:gh repo clone acme-corp/billing: fatal: Too many arguments.', diff --git a/src/integrations/datadog/redactRelayedMessage.ts b/src/integrations/datadog/redactRelayedMessage.ts index e9777c229..96c93bfc3 100644 --- a/src/integrations/datadog/redactRelayedMessage.ts +++ b/src/integrations/datadog/redactRelayedMessage.ts @@ -21,8 +21,10 @@ * * `ImportJobError` is the same relay for a failed load job, whose text quotes the rejected rows, and * `DeployRolloutError` for a rolling deploy's failed restart job, whose text quotes each node's error. + * `SecretsOperationError` for a failed secrets operation, whose text quotes the secret's and + * component's names (harper-pro `core/components/secretOperations.ts`). */ -const RELAYED_ERROR_TYPES = new Set(['SSEOperationError', 'ImportJobError', 'DeployRolloutError']); +const RELAYED_ERROR_TYPES = new Set(['SSEOperationError', 'ImportJobError', 'DeployRolloutError', 'SecretsOperationError']); const WITHHELD = 'Harper reported an operation failure (server message withheld).'; From b592d97cdecf8c78448c96eede38b89be20939aa Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sat, 10 Oct 2026 22:18:30 -0400 Subject: [PATCH 2/3] fix(secrets): leave a failure Harper did not explain as it was thrown Wrapping every rejection in SecretsOperationError also withheld failures that carry no Harper text at all: a network error or a bug reached Error Tracking only as the withheld placeholder. Only a response body with Harper's message now becomes a SecretsOperationError; anything else is rethrown unchanged, keeping its own type and message. That also stops a structured body from turning into "[object Object]". Cite the OSS harper path for the interpolated secrets messages. Refs #1664 Co-Authored-By: Claude Opus 5.5 --- .../api/instance/secrets/secrets.test.tsx | 59 +++++++++++++++---- .../api/instance/secrets/secrets.ts | 23 +++++--- .../datadog/redactRelayedMessage.ts | 2 +- 3 files changed, 62 insertions(+), 22 deletions(-) diff --git a/src/integrations/api/instance/secrets/secrets.test.tsx b/src/integrations/api/instance/secrets/secrets.test.tsx index a33c9420b..19599eb12 100644 --- a/src/integrations/api/instance/secrets/secrets.test.tsx +++ b/src/integrations/api/instance/secrets/secrets.test.tsx @@ -9,6 +9,7 @@ import { webcrypto } from 'node:crypto'; import { PropsWithChildren } from 'react'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { + SecretsOperationError, secretsPublicKeyQueryOptions, useDeleteSecret, useGrantSecret, @@ -68,6 +69,18 @@ function harness() { return { post, queryClient, wrapper, params }; } +/** Datadog reads the `name`, so match it as well as the class: dropping the assignment keeps `instanceof`. */ +async function expectSecretsOperationError(pending: Promise, message: string) { + const error = await pending.then(() => undefined, (rejection: unknown) => rejection); + expect(error).toBeInstanceOf(SecretsOperationError); + expect(error).toMatchObject({ name: 'SecretsOperationError', message }); +} + +/** What axios rejects with when the request never got a response. */ +function networkError() { + return Object.assign(new Error('Network Error'), { name: 'AxiosError', code: 'ERR_NETWORK' }); +} + describe('useSetSecret', () => { it('encrypts client-side and posts an envelope sealed with the served key', async () => { const { post, wrapper, params } = harness(); @@ -140,10 +153,24 @@ describe('useSetSecret', () => { }); const { result } = renderHook(() => useSetSecret(), { wrapper }); - await expect(result.current.mutateAsync({ ...params, name: 'API_KEY', value: 'x' })).rejects.toMatchObject({ - name: 'SecretsOperationError', - message: 'secrets custody is not initialized on this node', - }); + await expectSecretsOperationError( + result.current.mutateAsync({ ...params, name: 'API_KEY', value: 'x' }), + 'secrets custody is not initialized on this node', + ); + }); + + it('leaves a failure Harper did not explain as it was thrown', async () => { + const { post, wrapper, params } = harness(); + const key = await generateKey(); + const failure = networkError(); + post.mockImplementation((_url: string, body: { operation: string }) => + body.operation === 'get_secrets_public_key' + ? Promise.resolve({ data: { public_key: key.pem, fingerprint: key.fingerprint } }) + : Promise.reject(failure) + ); + + const { result } = renderHook(() => useSetSecret(), { wrapper }); + await expect(result.current.mutateAsync({ ...params, name: 'API_KEY', value: 'x' })).rejects.toBe(failure); }); it('fetches the key from central-manager for managed clusters (central-manager#409)', async () => { @@ -203,10 +230,14 @@ describe('useDeleteSecret', () => { expect(post).toHaveBeenCalledWith('/', { operation: 'delete_secret', name: 'API_KEY' }); post.mockRejectedValueOnce({ response: { data: { error: "No secret found with name 'NOPE'" } } }); - await expect(result.current.mutateAsync({ ...params, name: 'NOPE' })).rejects.toMatchObject({ - name: 'SecretsOperationError', - message: "No secret found with name 'NOPE'", - }); + await expectSecretsOperationError( + result.current.mutateAsync({ ...params, name: 'NOPE' }), + "No secret found with name 'NOPE'", + ); + + const failure = networkError(); + post.mockRejectedValueOnce(failure); + await expect(result.current.mutateAsync({ ...params, name: 'API_KEY' })).rejects.toBe(failure); }); }); @@ -225,9 +256,13 @@ describe.each( expect(post).toHaveBeenCalledWith('/', { operation, name: 'API_KEY', component: 'app' }); post.mockRejectedValueOnce({ response: { data: { error: "No secret found with name 'NOPE'" } } }); - await expect(result.current.mutateAsync({ ...params, name: 'NOPE', component: 'app' })).rejects.toMatchObject({ - name: 'SecretsOperationError', - message: "No secret found with name 'NOPE'", - }); + await expectSecretsOperationError( + result.current.mutateAsync({ ...params, name: 'NOPE', component: 'app' }), + "No secret found with name 'NOPE'", + ); + + const failure = networkError(); + post.mockRejectedValueOnce(failure); + await expect(result.current.mutateAsync({ ...params, name: 'API_KEY', component: 'app' })).rejects.toBe(failure); }); }); diff --git a/src/integrations/api/instance/secrets/secrets.ts b/src/integrations/api/instance/secrets/secrets.ts index ae08093ef..e278e94a5 100644 --- a/src/integrations/api/instance/secrets/secrets.ts +++ b/src/integrations/api/instance/secrets/secrets.ts @@ -62,10 +62,15 @@ export class SecretsOperationError extends Error { } } -/** Operations API errors carry the message in the response body; surface that, not the Axios noise. */ -function operationErrorMessage(error: unknown): string { - const body = (error as { response?: { data?: { error?: string; message?: string } } })?.response?.data; - return body?.error ?? body?.message ?? String(error); +/** + * Operations API errors carry Harper's message in the response body; surface that, not the Axios + * noise. A failure with no such message (the network, a bug) is not Harper's text, so it stays as + * thrown and Error Tracking keeps its type and message. + */ +function operationError(error: unknown): unknown { + const body = (error as { response?: { data?: { error?: unknown; message?: unknown } } })?.response?.data; + const message = body?.error ?? body?.message; + return typeof message === 'string' ? new SecretsOperationError(message) : error; } /** @@ -171,15 +176,15 @@ async function encryptAndSetSecret(queryClient: QueryClient, args: SetSecretArgs }); return data; } catch (error) { - const message = operationErrorMessage(error); + const failure = operationError(error); // A kid mismatch means our cached public key is stale (the custody key rotated). Drop the // cached key (ensureQueryData returns stale entries, so invalidation isn't enough) and // re-encrypt once; anything else (or a second mismatch) surfaces to the caller. - if (!isRetry && isKidMismatchError(message)) { + if (!isRetry && failure instanceof SecretsOperationError && isKidMismatchError(failure.message)) { queryClient.removeQueries({ queryKey: secretsPublicKeyQueryOptions(args).queryKey }); return encryptAndSetSecret(queryClient, args, true); } - throw new SecretsOperationError(message); + throw failure; } } @@ -199,7 +204,7 @@ async function deleteSecret({ instanceClient, name }: DeleteSecretArgs): Promise const { data } = await instanceClient.post('/', { operation: 'delete_secret', name }); return data; } catch (error) { - throw new SecretsOperationError(operationErrorMessage(error)); + throw operationError(error); } } @@ -226,7 +231,7 @@ async function mutateGrant( const { data } = await instanceClient.post('/', { operation, name, component }); return data; } catch (error) { - throw new SecretsOperationError(operationErrorMessage(error)); + throw operationError(error); } } diff --git a/src/integrations/datadog/redactRelayedMessage.ts b/src/integrations/datadog/redactRelayedMessage.ts index 96c93bfc3..b51b50940 100644 --- a/src/integrations/datadog/redactRelayedMessage.ts +++ b/src/integrations/datadog/redactRelayedMessage.ts @@ -22,7 +22,7 @@ * `ImportJobError` is the same relay for a failed load job, whose text quotes the rejected rows, and * `DeployRolloutError` for a rolling deploy's failed restart job, whose text quotes each node's error. * `SecretsOperationError` for a failed secrets operation, whose text quotes the secret's and - * component's names (harper-pro `core/components/secretOperations.ts`). + * component's names (harper `components/secretOperations.ts`). */ const RELAYED_ERROR_TYPES = new Set(['SSEOperationError', 'ImportJobError', 'DeployRolloutError', 'SecretsOperationError']); From 9d5fe201481eef377fc3948bee74fab3ba1474e6 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sat, 10 Oct 2026 22:24:47 -0400 Subject: [PATCH 3/3] fix(secrets): take Harper's string message when the error field is structured A body with a structured `error` and a string `message` short-circuited on the object and fell through to the raw AxiosError, so the string never became a SecretsOperationError. Prefer whichever field is a string. Refs #1664 Co-Authored-By: Claude Opus 5.5 --- .../api/instance/secrets/secrets.test.tsx | 14 +++++++++++++- src/integrations/api/instance/secrets/secrets.ts | 2 +- src/integrations/datadog/redactRelayedMessage.ts | 13 +++++++++---- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/integrations/api/instance/secrets/secrets.test.tsx b/src/integrations/api/instance/secrets/secrets.test.tsx index 19599eb12..cb5e310f4 100644 --- a/src/integrations/api/instance/secrets/secrets.test.tsx +++ b/src/integrations/api/instance/secrets/secrets.test.tsx @@ -76,7 +76,6 @@ async function expectSecretsOperationError(pending: Promise, message: s expect(error).toMatchObject({ name: 'SecretsOperationError', message }); } -/** What axios rejects with when the request never got a response. */ function networkError() { return Object.assign(new Error('Network Error'), { name: 'AxiosError', code: 'ERR_NETWORK' }); } @@ -239,6 +238,19 @@ describe('useDeleteSecret', () => { post.mockRejectedValueOnce(failure); await expect(result.current.mutateAsync({ ...params, name: 'API_KEY' })).rejects.toBe(failure); }); + + it('takes the string message when the error field is structured', async () => { + const { post, wrapper, params } = harness(); + post.mockRejectedValueOnce({ + response: { data: { error: { code: 'NOT_FOUND' }, message: "No secret found with name 'NOPE'" } }, + }); + + const { result } = renderHook(() => useDeleteSecret(), { wrapper }); + await expectSecretsOperationError( + result.current.mutateAsync({ ...params, name: 'NOPE' }), + "No secret found with name 'NOPE'", + ); + }); }); describe.each( diff --git a/src/integrations/api/instance/secrets/secrets.ts b/src/integrations/api/instance/secrets/secrets.ts index e278e94a5..5957c6f72 100644 --- a/src/integrations/api/instance/secrets/secrets.ts +++ b/src/integrations/api/instance/secrets/secrets.ts @@ -69,7 +69,7 @@ export class SecretsOperationError extends Error { */ function operationError(error: unknown): unknown { const body = (error as { response?: { data?: { error?: unknown; message?: unknown } } })?.response?.data; - const message = body?.error ?? body?.message; + const message = typeof body?.error === 'string' ? body.error : body?.message; return typeof message === 'string' ? new SecretsOperationError(message) : error; } diff --git a/src/integrations/datadog/redactRelayedMessage.ts b/src/integrations/datadog/redactRelayedMessage.ts index b51b50940..8f6286c6b 100644 --- a/src/integrations/datadog/redactRelayedMessage.ts +++ b/src/integrations/datadog/redactRelayedMessage.ts @@ -19,12 +19,17 @@ * `SSEOperationError`'s `code` and `phase`, which reach the browser but are not on the RUM event; * reporting them deliberately would restore that detail without the message. * - * `ImportJobError` is the same relay for a failed load job, whose text quotes the rejected rows, and - * `DeployRolloutError` for a rolling deploy's failed restart job, whose text quotes each node's error. - * `SecretsOperationError` for a failed secrets operation, whose text quotes the secret's and + * The same relay covers `ImportJobError` for a failed load job, whose text quotes the rejected rows; + * `DeployRolloutError` for a rolling deploy's failed restart job, whose text quotes each node's error; + * and `SecretsOperationError` for a failed secrets operation, whose text quotes the secret's and * component's names (harper `components/secretOperations.ts`). */ -const RELAYED_ERROR_TYPES = new Set(['SSEOperationError', 'ImportJobError', 'DeployRolloutError', 'SecretsOperationError']); +const RELAYED_ERROR_TYPES = new Set([ + 'SSEOperationError', + 'ImportJobError', + 'DeployRolloutError', + 'SecretsOperationError', +]); const WITHHELD = 'Harper reported an operation failure (server message withheld).';