Skip to content

Commit a80eebc

Browse files
committed
feat: sentry env deletion
1 parent f196f2c commit a80eebc

File tree

6 files changed

+333
-1
lines changed

6 files changed

+333
-1
lines changed

.circleci/config.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,9 @@ jobs:
605605
- run:
606606
name: Deactivate Github deployments
607607
command: pnpm --silent cli github:deployment:deactivate << pipeline.parameters.preview_deletion_branch >>
608+
- run:
609+
name: 'Delete Sentry environment issues'
610+
command: pnpm --silent cli sentry:delete-environment-issues << pipeline.parameters.preview_deletion_branch >>
608611

609612
commands:
610613
install_pnpm:
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { output, outputError } from '@app/cli/output'
2+
import { ServerWebAppConfig } from '@app/web/ServerWebAppConfig'
3+
import { Command } from '@commander-js/extra-typings'
4+
import {
5+
createSentryHttpClient,
6+
deleteIssuesByIds,
7+
listEnvironmentsWithIssues,
8+
listIssueIdsForEnvironment,
9+
listProjectEnvironments,
10+
type SentryConfig,
11+
updateProjectEnvironmentVisibility,
12+
} from './sentry-utils'
13+
14+
export const deleteSentryEnvironmentIssues = new Command()
15+
.command('sentry:delete-environment-issues')
16+
.argument('<environment>', 'environment')
17+
.action(async (environmentArgument) => {
18+
// We copy from computeBranchNamespace but keep digits for sentry env names
19+
const environment = environmentArgument
20+
// Replace special characters with hyphen
21+
.replaceAll(/[./@_]/g, '-')
22+
// When digits are removed, there might be multiple hyphens in a row
23+
.replaceAll(/--+/g, '-')
24+
// Remove prefix hyphen
25+
.replace(/^-/, '')
26+
// Namespace should be shorter than 32 chars to ensure all resources can be deployed
27+
.slice(0, 32)
28+
// Remove suffix hyphen
29+
.replace(/-$/, '')
30+
.toLowerCase()
31+
32+
if (environment === 'main') {
33+
output('You are trying to delete issues for the main environment')
34+
output('This is not allowed')
35+
process.exit(1)
36+
return
37+
}
38+
39+
const config: SentryConfig = {
40+
url: ServerWebAppConfig.Sentry.url,
41+
org: ServerWebAppConfig.Sentry.org,
42+
project: ServerWebAppConfig.Sentry.project,
43+
authToken: ServerWebAppConfig.Sentry.authToken,
44+
}
45+
const http = createSentryHttpClient(config)
46+
47+
// First, list all environments that currently have issues
48+
const envsWithIssues = await listEnvironmentsWithIssues(
49+
http,
50+
config.org,
51+
config.project,
52+
)
53+
const allEnvs = await listProjectEnvironments(
54+
http,
55+
config.org,
56+
config.project,
57+
)
58+
const envWithCount = new Map(
59+
envsWithIssues.map((e) => [e.environment, e.count]),
60+
)
61+
const zeroEnvs = allEnvs.filter((e) => !envWithCount.has(e))
62+
63+
if (envsWithIssues.length > 0) {
64+
output('Environments with issues:')
65+
for (const e of envsWithIssues) output(`- ${e.environment}: ${e.count}`)
66+
} else {
67+
output('No environments with issues found in Sentry')
68+
}
69+
70+
if (zeroEnvs.length > 0) {
71+
output('Environments with 0 issues:')
72+
for (const name of zeroEnvs) output(`- ${name}`)
73+
}
74+
75+
// If environment is not present in allEnvs, no-op
76+
if (!allEnvs.includes(environment)) {
77+
output(`Environment "${environment}" is not present in Sentry`)
78+
return
79+
}
80+
81+
output(
82+
`Fetching issues in Sentry environment "${environment}" for project ${config.org}/${config.project}...`,
83+
)
84+
const issueIds = await listIssueIdsForEnvironment(
85+
http,
86+
config.org,
87+
config.project,
88+
environment,
89+
)
90+
output(`Found ${issueIds.length} issue(s) to delete`)
91+
92+
if (issueIds.length > 0) {
93+
output('Deleting issues...')
94+
const { deletedCount, failedCount } = await deleteIssuesByIds(
95+
http,
96+
issueIds,
97+
)
98+
output(`Deleted ${deletedCount} issue(s)`)
99+
if (failedCount > 0)
100+
outputError(`Failed to delete ${failedCount} issue(s)`)
101+
}
102+
103+
try {
104+
const result = await updateProjectEnvironmentVisibility(
105+
http,
106+
config.org,
107+
config.project,
108+
environment,
109+
true,
110+
)
111+
output(`Environment "${result.name}" has been hidden from Sentry UI`)
112+
} catch {
113+
outputError('Failed to hide environment via Sentry API')
114+
}
115+
})
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { outputError } from '@app/cli/output'
2+
import axios, { type AxiosInstance } from 'axios'
3+
4+
export type SentryConfig = {
5+
url: string
6+
org: string
7+
project: string
8+
authToken: string
9+
}
10+
11+
export const readSentryConfigFromEnv = (): SentryConfig => {
12+
const url = (process.env.SENTRY_URL ?? 'https://sentry.io').replace(
13+
/\/+$/,
14+
'',
15+
)
16+
const org = process.env.SENTRY_ORG ?? ''
17+
const project = process.env.SENTRY_PROJECT ?? ''
18+
const authToken = process.env.SENTRY_AUTH_TOKEN ?? ''
19+
20+
if (!org || !project || !authToken) {
21+
outputError(
22+
'Missing Sentry configuration. Please set SENTRY_ORG, SENTRY_PROJECT, and SENTRY_AUTH_TOKEN.',
23+
)
24+
process.exit(1)
25+
}
26+
27+
return { url, org, project, authToken }
28+
}
29+
30+
export const createSentryHttpClient = (config: SentryConfig): AxiosInstance => {
31+
return axios.create({
32+
baseURL: `${config.url}/api/0`,
33+
headers: {
34+
Authorization: `Bearer ${config.authToken}`,
35+
'Content-Type': 'application/json',
36+
},
37+
timeout: 30000,
38+
})
39+
}
40+
41+
export const parseNextCursorFromLinkHeader = (
42+
linkHeader: string | undefined,
43+
): string | undefined => {
44+
if (!linkHeader) return undefined
45+
const parts = linkHeader.split(',')
46+
for (const part of parts) {
47+
const segment = part.trim()
48+
const isNext = /rel="next"/.test(segment)
49+
const hasResults = /results="true"/.test(segment)
50+
if (isNext && hasResults) {
51+
const match = segment.match(/cursor="([^"]+)"/)
52+
if (match && match[1]) return match[1]
53+
}
54+
}
55+
return undefined
56+
}
57+
58+
export const listIssueIdsForEnvironment = async (
59+
http: AxiosInstance,
60+
org: string,
61+
project: string,
62+
environment: string,
63+
): Promise<string[]> => {
64+
const collectedIssueIds: string[] = []
65+
let cursor: string | undefined
66+
do {
67+
// Per Sentry docs: GET /projects/{org_slug}/{project_slug}/issues/
68+
// Filter by environment via the 'query' search param
69+
const response = await http.get(
70+
`/projects/${encodeURIComponent(org)}/${encodeURIComponent(project)}/issues/`,
71+
{
72+
params: {
73+
query: `environment:${environment}`,
74+
limit: 100,
75+
cursor,
76+
// Optionally ensure only unresolved
77+
// query: `environment:${environment}`,
78+
},
79+
},
80+
)
81+
const issues = Array.isArray(response.data) ? response.data : []
82+
for (const issue of issues) {
83+
if (issue && typeof issue.id === 'string')
84+
collectedIssueIds.push(issue.id)
85+
}
86+
const linkHeader: string | undefined =
87+
typeof (response.headers as any).get === 'function'
88+
? (response.headers as any).get('link')
89+
: (response.headers as Record<string, string | undefined>).link
90+
cursor = parseNextCursorFromLinkHeader(linkHeader)
91+
} while (cursor)
92+
return collectedIssueIds
93+
}
94+
95+
export const deleteIssuesByIds = async (
96+
http: AxiosInstance,
97+
issueIds: string[],
98+
): Promise<{ deletedCount: number; failedCount: number }> => {
99+
let deletedCount = 0
100+
let failedCount = 0
101+
for (const issueId of issueIds) {
102+
try {
103+
// Per Sentry docs: DELETE /issues/{issue_id}/
104+
await http.delete(`/issues/${issueId}/`)
105+
deletedCount += 1
106+
} catch (error) {
107+
failedCount += 1
108+
const message = axios.isAxiosError(error)
109+
? `${error.response?.status} ${error.response?.statusText}`
110+
: String(error)
111+
outputError(`Failed to delete issue ${issueId}: ${message}`)
112+
}
113+
}
114+
return { deletedCount, failedCount }
115+
}
116+
117+
export type SentryEnvironmentWithCount = {
118+
environment: string
119+
count: number
120+
}
121+
122+
// Uses Sentry tags API to list environments that appear on issues (by events count)
123+
// GET /api/0/projects/{org_slug}/{project_slug}/tags/environment/values/
124+
export const listEnvironmentsWithIssues = async (
125+
http: AxiosInstance,
126+
org: string,
127+
project: string,
128+
): Promise<SentryEnvironmentWithCount[]> => {
129+
const aggregated = new Map<string, number>()
130+
let cursor: string | undefined
131+
do {
132+
const response = await http.get(
133+
`/projects/${encodeURIComponent(org)}/${encodeURIComponent(project)}/tags/environment/values/`,
134+
{
135+
params: { limit: 100, cursor },
136+
},
137+
)
138+
const values = Array.isArray(response.data) ? response.data : []
139+
for (const v of values) {
140+
const env = typeof v.value === 'string' ? v.value : undefined
141+
const count = typeof v.count === 'number' ? v.count : 0
142+
if (!env) continue
143+
aggregated.set(env, (aggregated.get(env) ?? 0) + count)
144+
}
145+
const linkHeader: string | undefined =
146+
typeof (response.headers as any).get === 'function'
147+
? (response.headers as any).get('link')
148+
: (response.headers as Record<string, string | undefined>).link
149+
cursor = parseNextCursorFromLinkHeader(linkHeader)
150+
} while (cursor)
151+
152+
return Array.from(aggregated.entries())
153+
.map(([environment, count]) => ({ environment, count }))
154+
.sort((a, b) => b.count - a.count)
155+
}
156+
157+
export const listProjectEnvironments = async (
158+
http: AxiosInstance,
159+
org: string,
160+
project: string,
161+
): Promise<string[]> => {
162+
const environments = new Set<string>()
163+
let cursor: string | undefined
164+
do {
165+
const response = await http.get(
166+
`/projects/${encodeURIComponent(org)}/${encodeURIComponent(project)}/environments/`,
167+
{
168+
params: { limit: 100, cursor },
169+
},
170+
)
171+
const values = Array.isArray(response.data) ? response.data : []
172+
for (const v of values) {
173+
const name = typeof v.name === 'string' ? v.name : undefined
174+
if (name) environments.add(name)
175+
}
176+
const linkHeader: string | undefined =
177+
typeof (response.headers as any).get === 'function'
178+
? (response.headers as any).get('link')
179+
: (response.headers as Record<string, string | undefined>).link
180+
cursor = parseNextCursorFromLinkHeader(linkHeader)
181+
} while (cursor)
182+
183+
return Array.from(environments).sort((a, b) => a.localeCompare(b))
184+
}
185+
186+
export const updateProjectEnvironmentVisibility = async (
187+
http: AxiosInstance,
188+
org: string,
189+
project: string,
190+
environment: string,
191+
isHidden: boolean,
192+
): Promise<{ name: string; isHidden: boolean }> => {
193+
const response = await http.put(
194+
`/projects/${encodeURIComponent(org)}/${encodeURIComponent(project)}/environments/${encodeURIComponent(environment)}/`,
195+
{ isHidden },
196+
)
197+
const name =
198+
typeof response.data?.name === 'string' ? response.data.name : environment
199+
const hidden = Boolean(response.data?.isHidden)
200+
return { name, isHidden: hidden }
201+
}

apps/cli/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { getDatabasePasswordSecret } from '@app/cli/commands/secrets/getDatabase
1111
import { getSecretValue } from '@app/cli/commands/secrets/getSecretValue'
1212
import { listSecrets } from '@app/cli/commands/secrets/listSecrets'
1313
import { setupDatabaseSecret } from '@app/cli/commands/secrets/setupDatabaseSecret'
14+
import { deleteSentryEnvironmentIssues } from '@app/cli/commands/sentry/deleteSentryEnvironmentIssues'
1415
import { Command } from '@commander-js/extra-typings'
1516

1617
const program = new Command()
@@ -28,5 +29,6 @@ program.addCommand(updateGithubDeployment)
2829
program.addCommand(deactivateGithubDeployment)
2930
program.addCommand(createTfVarsFileFromEnvironment)
3031
program.addCommand(checkDeploymentStatus)
32+
program.addCommand(deleteSentryEnvironmentIssues)
3133

3234
program.parse()

apps/cli/src/output.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
/**
2-
* Output utility for CLI commands
2+
* Output fonction for CLI Commands.
3+
* Express the intent for CLI output instead of debug console.log that are
4+
* forbidden by our lint rules
35
*/
6+
// biome-ignore lint/suspicious/noConsole: feature for cli package
47
export const output = console.log
8+
// biome-ignore lint/suspicious/noConsole: feature for cli package
9+
export const outputError = console.error

apps/web/src/ServerWebAppConfig.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,10 @@ export const ServerWebAppConfig = {
4545
Database: {
4646
instanceId: process.env.DATABASE_INSTANCE_ID ?? '', // like fr-par/uuid
4747
},
48+
Sentry: {
49+
authToken: process.env.SENTRY_AUTH_TOKEN ?? '',
50+
url: process.env.SENTRY_URL ?? '',
51+
org: process.env.SENTRY_ORG ?? '',
52+
project: process.env.SENTRY_PROJECT ?? '',
53+
},
4854
}

0 commit comments

Comments
 (0)