Sentry Releases and Source Maps
Sentry SDK를 붙이기만 하면 운영 오류는 압축된 번들의 파일, 줄, 열로 남습니다. 앱 웹뷰 안에서 동작하는 React 서비스에서 소스맵 업로드와 릴리스 이름, 배포물 정리, 수집 범위를 차례로 손봤습니다. 그때의 구성을 Sentry JavaScript SDK 11.9.0, 번들러 플러그인 11.9.0, sentry-cli 3.8.2 기준의 공식 문서와 패키지 소스로 다시 확인해 정리했습니다.
압축된 위치를 원본으로 되돌리는 두 방식
브라우저가 보내는 스택 트레이스에는 배포된 번들 기준 위치가 담깁니다. Sentry가 이를 원본 파일과 줄 번호로 바꾸려면 그 번들을 만든 소스맵을 찾아야 합니다. 찾는 방식은 두 가지입니다.
| 방식 | 연결 기준 | 조건 |
|---|---|---|
| Debug ID | 빌드 결과 파일마다 넣은 고유 id | SDK 7.47.0 이상, sentry-cli 2.17.0 이상 |
| 릴리스 기반(레거시) | 릴리스 이름, dist, 파일 경로 | Sentry.init의 release가 업로드 릴리스와 정확히 같아야 함 |
현재 문서의 기본은 Debug ID입니다. sentry-cli sourcemaps inject나 번들러 플러그인이 압축된 파일 끝에 //# debugId= 주석을 넣고 소스맵에도 같은 id를 기록합니다. 문서는 Debug ID를 쓰면 소스맵 연결을 위해 릴리스를 만들 필요가 없다고 설명합니다. 예전에는 배포마다 같은 파일 이름이 반복되기 때문에 릴리스 이름으로 소스맵을 구분했습니다.
flowchart LR
Build["빌드: 번들과 .map 생성"] --> Inject["inject: 파일마다 Debug ID"]
Inject --> Upload["upload: 번들과 .map 업로드"]
Upload --> Remove["배포물에서 .map 삭제"]
Remove --> Deploy["정적 파일 배포"]
Deploy --> Event["오류 이벤트에 Debug ID 포함"]
Event --> Resolve["같은 Debug ID의 소스맵으로 원본 위치 복원"]예전 블로그 글에서 자주 보이는 sentry-cli releases files ... upload-sourcemaps 명령은 sentry-cli 3.0에서 제거됐습니다. 지금은 sentry-cli sourcemaps upload를 씁니다.
릴리스 이름은 한 곳에서 계산합니다
Debug ID가 소스맵 연결을 맡아도 릴리스 이름은 여전히 필요합니다. 문서가 꼽는 릴리스의 쓰임은 새 릴리스에서 생긴 이슈와 회귀 판단, 원인 커밋 추정, 커밋 메시지로 이슈 해결, 배포 알림, 릴리스 상태(사용자 비율과 크래시 비율) 확인입니다. 모든 배포가 한 이름을 쓰면 이 기능이 의미를 잃습니다.
처음 붙일 때는 앱 초기화와 업로드 명령에 같은 고정 문자열을 넣었습니다. 이후 환경 이름, package.json 버전, 환경별 버전 값으로 이름을 만들고 앱과 업로드 스크립트가 같은 계산식을 쓰게 바꿨습니다. 같은 식을 두 곳에 복사해 두면 언젠가 한쪽만 바뀝니다. 계산 함수는 하나만 두고 빌드 설정과 업로드 스크립트가 함께 불러 쓰게 합니다.
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
const gitSha = () => execFileSync('git', ['rev-parse', '--short', 'HEAD']).toString().trim()
// 앱 빌드와 업로드 스크립트가 모두 이 함수만 사용한다
export function releaseName(appEnv, buildNumber = process.env.BUILD_NUMBER ?? gitSha()) {
const { version } = JSON.parse(readFileSync('package.json', 'utf8'))
return `web@${version}+${appEnv}.${buildNumber}`
}Vite를 쓴다면 번들러 플러그인에 이 이름을 넘기는 것으로 충분합니다. 플러그인의 release.inject는 기본값이 true이고 SDK 11.9.0은 release 옵션이 비어 있으면 플러그인이 번들에 넣은 값을 읽습니다. Sentry.init에 release를 직접 적는다면 플러그인의 release.name과 정확히 같아야 합니다.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { sentryVitePlugin } from '@sentry/vite-plugin'
import { releaseName } from './scripts/release-name.mjs'
const UPLOAD_MODES = new Set(['stg', 'prd'])
export default defineConfig(({ mode }) => ({
build: { sourcemap: 'hidden' },
plugins: [
react(),
// Sentry 플러그인은 다른 플러그인보다 뒤에 둔다
sentryVitePlugin({
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
disable: !UPLOAD_MODES.has(mode),
release: { name: releaseName(mode) },
sourcemaps: { filesToDeleteAfterUpload: ['./dist/**/*.map'] },
}),
],
}))업로드한 소스맵은 배포물에 남기지 않습니다
공개된 .map 파일이 있으면 누구나 원본 코드와 주석, 내부 API 경로를 복원할 수 있습니다. Sentry 문서도 소스맵을 만들면 소스 코드가 노출될 수 있다고 경고하고 업로드 뒤 삭제를 권합니다.
Vite의 build.sourcemap: 'hidden'은 소스맵을 만들지만 번들 파일의 sourceMappingURL 주석을 빼는 옵션입니다. 브라우저가 소스맵을 자동으로 찾지 않을 뿐 파일은 그대로 남습니다. 그래서 hidden과 업로드 뒤 삭제를 함께 씁니다. 위 설정의 filesToDeleteAfterUpload가 삭제를 맡습니다.
번들러 플러그인을 쓰지 않는 빌드에서는 sentry-cli로 같은 순서를 직접 실행했습니다. 정적 파일을 CDN에 올리는 구조여서 빌드 스크립트 안에서 빌드, 업로드, 삭제를 이어 실행했고 stg와 prd 빌드는 업로드 뒤 .map을 지웠습니다.
import { execFileSync } from 'node:child_process'
import { releaseName } from './release-name.mjs'
const release = releaseName(process.env.APP_ENV)
const cli = (...args) => execFileSync('sentry-cli', args, { stdio: 'inherit' })
try {
cli('sourcemaps', 'inject', './build')
cli('releases', 'new', release)
cli('sourcemaps', 'upload', `--release=${release}`, './build')
cli('releases', 'finalize', release)
execFileSync('find', ['./build', '-name', '*.map', '-delete'])
} catch (error) {
console.error(error)
process.exit(1) // 업로드가 실패하면 배포도 멈춘다
}순서에서 지킬 것은 세 가지입니다.
inject가 바꾼 파일을 배포해야 합니다. 주입 전 파일을 올리면 이벤트에 Debug ID가 없어 소스맵을 찾지 못합니다.sourcemaps upload에--release를 붙여도 릴리스가 자동으로 만들어지지는 않습니다. 문서에 따르면 같은 이름의 릴리스를 별도 단계로 만들거나 그 이름의 첫 이벤트를 기다려야 합니다. 위 스크립트가releases new를 먼저 부르는 것도 그래서입니다.- 업로드가 실패하면 빌드를 실패시킵니다. 소스맵 없이 배포되면 그 배포의 오류는 끝까지 압축된 위치로 남습니다.
Next.js는 방식이 다릅니다. @sentry/nextjs 11.9.0의 withSentryConfig는 SDK가 만든 소스맵을 업로드 뒤 지우는 sourcemaps.deleteSourcemapsAfterUpload가 기본으로 켜져 있습니다. 예전 설정 예제에 자주 나오는 hideSourceMaps 옵션은 11.9.0 타입 정의에 없습니다.
샘플링은 비용과 재현 가능성을 맞바꿉니다
릴리스 이름을 맞추면서 세션 리플레이도 붙였습니다. 일반 세션은 10%, 오류가 난 세션은 100%를 기록하는 설정이었습니다. 2주 뒤에는 리플레이를 빼고 성능 추적 샘플링을 개발 외 환경에서 100%에서 10%로 낮췄습니다. 리플레이를 뺀 이유는 당시 기록으로 확인하지 못해 여기서는 판단 기준만 정리합니다.
리플레이 샘플링은 두 값으로 나뉩니다.
replaysSessionSampleRate: 세션 전체를 기록할 비율입니다.replaysOnErrorSampleRate: 오류가 난 세션을 기록할 비율입니다. 전체 기록에 뽑히지 않은 세션도 최근 60초를 메모리에 계속 담아 두고 오류가 나면 그 60초와 나머지 세션을 보냅니다.
두 번째 값을 1.0으로 두면 모든 세션에서 기록기가 메모리 버퍼로 동작한다는 뜻입니다. 오류를 재현하기에는 좋지만 저사양 기기와 웹뷰에서 그 비용이 괜찮은지는 직접 재 봐야 합니다. 문서는 하루 세션 수에 따라 세션 비율 1%, 10%, 25%와 오류 비율 100%를 예로 들고 초기 번들 크기를 줄이는 지연 로딩도 안내합니다. 리플레이는 기본으로 모든 텍스트, 이미지와 입력을 가립니다. 주문서나 개인정보 입력 화면이 있다면 이 기본값을 유지합니다.
import * as Sentry from '@sentry/react'
const env = import.meta.env.MODE
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
enabled: env === 'stg' || env === 'prd',
environment: env,
// release를 비워 두면 번들러 플러그인이 넣은 값을 읽는다
tracesSampleRate: env === 'prd' ? 0.1 : 1.0,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: env === 'prd' ? 1.0 : 0,
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
],
})tracesSampleRate는 트레이스 하나가 전송될 확률입니다. 경로별로 비율을 달리하려면 tracesSampler를 쓰는데 둘 다 있으면 tracesSampleRate는 무시됩니다.
이벤트에 찾을 수 있는 정보를 붙입니다
원본 위치가 보여도 어떤 요청에서 실패했는지 모르면 다시 재현해야 합니다. 처음 Sentry를 검토할 때 정리한 기준은 지금 문서와도 같습니다.
| 수단 | 용도 | 검색 |
|---|---|---|
| tag | 엔드포인트, 상태 코드처럼 걸러 볼 키와 값 | 가능, 알림 조건에도 사용 |
| context | 요청 id, 응답 일부처럼 이슈 화면에서 볼 구조화 데이터 | 불가 |
| level | 같은 오류라도 중요도 구분 | 가능 |
| 오류 클래스 | 이슈 제목과 묶음 기준 | 제목으로 구분 |
| breadcrumb | 오류 직전 사용자 동작과 앱 상태 | 이벤트 안에서 확인 |
기본 Error로 보내면 서로 다른 API 실패가 비슷한 제목으로 모입니다. 전용 오류 클래스를 두면 제목과 묶음이 분명해집니다.
import * as Sentry from '@sentry/react'
export class ApiError extends Error {
constructor(readonly status: number, readonly endpoint: string, readonly requestId?: string) {
super(`API ${status} ${endpoint}`)
this.name = 'ApiError'
}
}
export function reportApiError(error: ApiError) {
Sentry.captureException(error, {
level: error.status >= 500 ? 'error' : 'warning',
tags: { 'api.endpoint': error.endpoint, 'api.status': String(error.status) },
// 응답 본문에 개인정보가 있을 수 있어 요청 id만 남긴다
contexts: { api: { requestId: error.requestId } },
})
}수집 범위를 줄인 다음 날에는 원인을 좁히기 어려웠던 두 지점에 breadcrumb을 남겼습니다. 여러 단계를 거쳐 고른 조합의 결과가 비어 있는 경우와 수정 화면에 필요한 이미지 주소가 없는 경우였습니다. 같은 시기에 앱 최상위에 오류 경계도 두었습니다.
확인 방법과 한계
stg에 배포한 뒤 일부러 오류를 던져 이슈 화면의 스택이 원본 파일과 줄 번호로 보이는지 확인합니다. 그대로 압축된 위치라면 이슈 화면의 “Unminify Code” 버튼으로 원인을 찾습니다. sentry-cli 3.0은 예전의 sourcemaps explain 명령을 없애고 이 버튼을 대안으로 안내합니다. 배포된 경로에서 .map 요청이 404인지와 번들에 sourceMappingURL 주석이 없는지도 함께 봅니다.
소스맵은 위치만 알려 줍니다. 원인은 tag, context, breadcrumb과 리플레이로 좁혀야 하고 샘플링으로 빠진 세션의 문제는 보이지 않습니다. 버전에 따른 차이도 큽니다. 예전 설정 예제의 hideSourceMaps 옵션과 releases files 명령은 지금 버전에 없으므로 옛 예제를 옮길 때는 패키지 버전부터 확인합니다.
참고 자료
- Sentry: Source Maps
- Sentry: What are Artifact Bundles
- Sentry: Uploading Source Maps with Sentry CLI
- Sentry: Uploading Source Maps with Vite
- Sentry: Legacy Uploading Methods
- Sentry: Next.js Source Maps
- Sentry: Releases
- Sentry: Session Replay
- Sentry: Sampling
- Sentry: Context
- Sentry: Tags
- Sentry: Breadcrumbs
- Sentry: Issue Properties
- sentry-cli 3.0.0 릴리스 노트
- Vite: build.sourcemap